sofiamak commited on
Commit
1ecaafa
·
verified ·
1 Parent(s): 4810854

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +282 -0
app.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, os, html, random
2
+ import gradio as gr
3
+
4
+ # =============== 新增:DeepSeek 客户端(OpenAI 兼容) ===============
5
+ try:
6
+ from openai import OpenAI
7
+ _HAS_OPENAI = True
8
+ client = OpenAI(
9
+ api_key=os.getenv("DEEPSEEK_API_KEY"),
10
+ base_url="https://api.deepseek.com/v1"
11
+ )
12
+ except Exception:
13
+ _HAS_OPENAI = False
14
+ # ================================================================
15
+
16
+ APP_TITLE = "孙子你好"
17
+ DATA_FILE = "playbook.json"
18
+
19
+ # 读取固定答案库
20
+ def load_kb():
21
+ if not os.path.exists(DATA_FILE):
22
+ raise RuntimeError("未找到 playbook.json,请先上传。")
23
+ with open(DATA_FILE, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ # 章节小副标(可根据需要增补)
26
+ chap_hint = {
27
+ "计篇":"先算后战","作战篇":"速战速决","谋攻篇":"上兵伐谋","军形篇":"先固己势",
28
+ "兵势篇":"势能制胜","虚实篇":"避实击虚","军争篇":"抢占先机","九变篇":"因变制胜",
29
+ "行军篇":"察情定位","地形篇":"因地用兵","九地篇":"九地九策","火攻篇":"借势放大",
30
+ "用间篇":"情报为先","始计篇":"先计而后战"
31
+ }
32
+ for item in data:
33
+ item["chapter_hint"] = chap_hint.get(item.get("chapter",""), "")
34
+ item["triggers"] = item.get("triggers", [])
35
+ return data
36
+
37
+ KB = load_kb()
38
+
39
+ def match_candidates(user_text: str):
40
+ """
41
+ 简单触发词计分 → 取最高分 intent → 返回该 intent 的条目池(打乱顺序)。
42
+ 若无匹配,返回空池和提示,交给 DeepSeek 回退生成。
43
+ """
44
+ text = (user_text or "").lower()
45
+ scored = []
46
+ for it in KB:
47
+ score = sum(1 for t in it.get("triggers", []) if t.lower() in text)
48
+ if score > 0:
49
+ scored.append((score, it))
50
+ if not scored:
51
+ return [], "(未匹配到明确场景,已用智能推荐生成)"
52
+ scored.sort(key=lambda x: -x[0])
53
+ top_intent = scored[0][1]["intent"]
54
+ pool = [it for _, it in scored if it["intent"] == top_intent]
55
+ random.shuffle(pool)
56
+ return pool, ""
57
+
58
+ # =============== 新增:将 DeepSeek 输出为与本地条目一致的结构 ===============
59
+ CHAP_HINT = {
60
+ "计篇":"先算后战","作战篇":"速战速决","谋攻篇":"上兵伐谋","军形篇":"先固己势",
61
+ "兵势篇":"势能制胜","虚实篇":"避实击虚","军争篇":"抢占先机","九变篇":"因变制胜",
62
+ "行军篇":"察情定位","地形篇":"因地用兵","九地篇":"九地九策","火攻篇":"借势放大",
63
+ "用间篇":"情报为先","始计篇":"先计而后战"
64
+ }
65
+
66
+ def ai_generate_item(user_text: str) -> dict:
67
+ """
68
+ 调用 DeepSeek 让模型输出 JSON:
69
+ {"chapter": "...", "quote": "...", "idea": "...", "methods": ["...","...","..."], "speech": "..."}
70
+ 如失败则返回一个简易兜底条目。
71
+ """
72
+ if not _HAS_OPENAI:
73
+ return {
74
+ "chapter": "始计篇",
75
+ "chapter_hint": CHAP_HINT.get("始计篇",""),
76
+ "quote": "先计而后战。",
77
+ "idea": "先捋清状况,再出手更稳。",
78
+ "methods": ["明确目标与约束","先礼后兵,给台阶","设期限与分步方案"],
79
+ "speech": "我先把情况梳理一下,给你两个可选方案,我们按这个节奏推进。"
80
+ }
81
+
82
+ sys = (
83
+ "你是中文军师,用《孙子兵法》给出策略。"
84
+ "请严格输出一个 JSON 对象,字段仅限:"
85
+ "chapter(篇名,如“虚实篇/谋攻篇/始计篇”等)、"
86
+ "quote(兵法原文,尽量贴切)、"
87
+ "idea(思路,1-2句)、"
88
+ "methods(数组,3条以内,简短可执行)、"
89
+ "speech(一段可直接使用的话术,口吻礼貌、委婉、幽默有边界)。"
90
+ )
91
+ user = f"用户问题:{user_text}\n只输出 JSON。不要任何解释或前后缀。"
92
+ try:
93
+ resp = client.chat.completions.create(
94
+ model="deepseek-chat",
95
+ messages=[
96
+ {"role":"system","content":sys},
97
+ {"role":"user","content":user}
98
+ ],
99
+ temperature=0.6,
100
+ max_tokens=600
101
+ )
102
+ content = resp.choices[0].message.content.strip()
103
+ # 尝试提取 JSON
104
+ import json as _json, re as _re
105
+ # 清除围栏
106
+ m = _re.search(r"\{.*\}", content, flags=_re.S)
107
+ js = m.group(0) if m else content
108
+ data = _json.loads(js)
109
+ chapter = str(data.get("chapter","始计篇"))
110
+ item = {
111
+ "chapter": chapter,
112
+ "chapter_hint": CHAP_HINT.get(chapter, ""),
113
+ "quote": str(data.get("quote","先计而后战。")),
114
+ "idea": str(data.get("idea","先捋清状况,再出手更稳。")),
115
+ "methods": list(data.get("methods", []))[:3] or ["设清晰目标","给台阶与期限","分步推进"],
116
+ "speech": str(data.get("speech","我们这样推进最稳,也方便��此配合。"))
117
+ }
118
+ return item
119
+ except Exception as e:
120
+ # 兜底
121
+ return {
122
+ "chapter": "虚实篇",
123
+ "chapter_hint": CHAP_HINT.get("虚实篇",""),
124
+ "quote": "避实而击虚。",
125
+ "idea": "避开正面冲突,转到对方更在意的点。",
126
+ "methods": ["承诺结果而非加班","给两个选项让其选择","设置温和期限"],
127
+ "speech": "我更关注把结果做好,不如按这个更稳的节奏来?您看哪种更合适。"
128
+ }
129
+ # =================================================================
130
+
131
+ def render_plan(item, show_speech=False, tip_msg=""):
132
+ """渲染完整方案;show_speech=True 时附带话术卡片"""
133
+ chap = html.escape(item.get("chapter",""))
134
+ hint = html.escape(item.get("chapter_hint",""))
135
+ quote= html.escape(item.get("quote",""))
136
+ idea = html.escape(item.get("idea",""))
137
+ methods = item.get("methods", [])
138
+ methods_html = "".join([f"<div> {i+1}. {html.escape(m)}</div>" for i,m in enumerate(methods)])
139
+ speech = html.escape(item.get("speech","")).replace("\n","<br>")
140
+
141
+ speech_html = f"""
142
+ <div style="margin-top:10px;padding:12px 14px;border:1px dashed #e2d7f2;border-radius:10px;background:#fbf8ff;">
143
+ <div style="font-weight:600;">💬 话术示例:</div>
144
+ <div>{speech}</div>
145
+ </div>
146
+ """ if (show_speech and speech) else ""
147
+
148
+ tip = f"<div style='color:#9a8fb3;font-size:14px;margin-bottom:6px;'>{html.escape(tip_msg)}</div>" if tip_msg else ""
149
+
150
+ return f"""
151
+ <div style="font-size:22px;line-height:1.7em;">
152
+ {tip}
153
+ 🧠 《{chap}》:{hint}<br><br>
154
+ 「{quote}」<br><br>
155
+ ⸻<br><br>
156
+ <span style="font-weight:600;">思路:</span>{idea}<br><br>
157
+ <span style="font-weight:600;">做法:</span>
158
+ {methods_html}
159
+ {speech_html}
160
+ </div>
161
+ """
162
+
163
+ def render_speech_only(item):
164
+ """只显示话术示例卡片"""
165
+ speech = html.escape(item.get("speech","")).replace("\n", "<br>")
166
+ return f"""
167
+ <div style="font-size:22px;line-height:1.8em;padding:20px;background:#fbf8ff;border-radius:12px;border:1px solid #e2d7f2;">
168
+ <div style="font-weight:600;margin-bottom:10px;">💬 话术示例:</div>
169
+ <div>{speech}</div>
170
+ </div>
171
+ """
172
+
173
+ # 为了控制按钮显隐,统一一个输出打包方法
174
+ def pack_outputs(html_out, pool, idx, show_flag, show_switch, show_speech_btn, show_back, show_again, user_text_keep=True):
175
+ return (
176
+ html_out,
177
+ pool,
178
+ idx,
179
+ show_flag,
180
+ gr.update(visible=show_switch), # btn_switch
181
+ gr.update(visible=show_speech_btn), # btn_speech
182
+ gr.update(visible=show_back), # btn_back
183
+ gr.update(visible=show_again), # btn_again
184
+ (gr.update() if user_text_keep else gr.update(value="")) # Textbox
185
+ )
186
+
187
+ # ---- 回调:生成 / 换兵法 / 只看话术 / 返回兵法 / 重新提问 ----
188
+ def on_generate(user_text, state_pool, state_idx, state_show):
189
+ pool, tip = match_candidates(user_text)
190
+
191
+ # 若没命中本地语料:调用 DeepSeek 生成一个“条目”,塞入池
192
+ if not pool:
193
+ ai_item = ai_generate_item(user_text)
194
+ pool = [ai_item]
195
+
196
+ # 生成后:显示“换兵法”“显示话术示例”“问孙子其他问题”,不显示“返回兵法”
197
+ html_out = render_plan(pool[0], False, tip)
198
+ return pack_outputs(html_out, pool, 0, False, True, True, False, True, True)
199
+
200
+ def on_switch(state_pool, state_idx, state_show):
201
+ pool = state_pool or []
202
+ if not pool:
203
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>",
204
+ pool, state_idx, state_show, False, False, False, False, True)
205
+ idx = (state_idx + 1) % len(pool)
206
+ # 切换后:仍显示“换兵法”“显示话术示例”“问孙子其他问题”,隐藏“返回兵法”
207
+ html_out = render_plan(pool[idx], False, "")
208
+ return pack_outputs(html_out, pool, idx, False, True, True, False, True, True)
209
+
210
+ def on_show_speech(state_pool, state_idx, state_show):
211
+ """只显示话术示例;此时需要出现“返回兵法”"""
212
+ pool = state_pool or []
213
+ if not pool:
214
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>",
215
+ pool, state_idx, state_show, False, False, False, False, True)
216
+ idx = max(0, min(state_idx, len(pool)-1))
217
+ html_out = render_speech_only(pool[idx])
218
+ # 此时:显示“返回兵法”“换兵法”“显示话术示例”“问孙子其他问题”都可以保留
219
+ return pack_outputs(html_out, pool, idx, True, True, True, True, True, True)
220
+
221
+ def on_back(state_pool, state_idx, state_show):
222
+ """从“只看话术”返回到完整兵法视图;隐藏‘返回兵法’"""
223
+ pool = state_pool or []
224
+ if not pool:
225
+ return pack_outputs("<div style='font-size:20px;'>请��输入你的问题并点击生成兵法。</div>",
226
+ pool, state_idx, state_show, False, False, False, False, True)
227
+ idx = max(0, min(state_idx, len(pool)-1))
228
+ html_out = render_plan(pool[idx], False, "")
229
+ return pack_outputs(html_out, pool, idx, False, True, True, False, True, True)
230
+
231
+ def on_again(state_pool, state_idx, state_show):
232
+ """回到初始状态:清空输出与输入,只显示生成兵法"""
233
+ html_out = ""
234
+ empty_pool = []
235
+ # 初始:只有“生成兵法”可见(其它按钮隐藏)
236
+ return pack_outputs(html_out, empty_pool, 0, False, False, False, False, False, user_text_keep=False)
237
+
238
+ # ---- UI ----
239
+ theme = gr.themes.Soft(primary_hue="pink", neutral_hue="slate")
240
+ CSS = """
241
+ body,.gradio-container{background:#f7f6fb!important;}
242
+ .header{font-weight:900;font-size:28px;color:#111;text-align:center;padding:24px 0;}
243
+ .card{background:#fff;border:1px solid #eddcf3;border-radius:14px; box-shadow:0 4px 18px rgba(0,0,0,.05);padding:16px;}
244
+ """
245
+
246
+ with gr.Blocks(title=APP_TITLE, theme=theme, css=CSS, analytics_enabled=False) as demo:
247
+ gr.HTML("<div class='header'>孙子你好</div>")
248
+ with gr.Group(elem_classes=["card"]):
249
+ user_text = gr.Textbox(label="你的问题", placeholder="例如:朋友借钱不还;老板让我加班;邻居太吵;甲方拖款…", lines=3)
250
+ btn_gen = gr.Button("🎯 生成兵法", variant="primary")
251
+
252
+ with gr.Row():
253
+ btn_switch = gr.Button("♻️ 换兵法", visible=False)
254
+ btn_speech = gr.Button("💬 显示示例话术", visible=False)
255
+ btn_back = gr.Button("↩️ 返回兵法", visible=False)
256
+ btn_again = gr.Button("🧭 问孙子其他问题", visible=False)
257
+
258
+ out = gr.HTML()
259
+
260
+ # 状态:候选池、当前下标、是否处于“只看话术”
261
+ state_pool = gr.State([])
262
+ state_idx = gr.State(0)
263
+ state_show = gr.State(False)
264
+
265
+ # 事件绑定(注意输出多了按钮可见性与文本框)
266
+ btn_gen.click(on_generate, inputs=[user_text, state_pool, state_idx, state_show],
267
+ outputs=[out, state_pool, state_idx, state_show, btn_switch, btn_speech, btn_back, btn_again, user_text])
268
+
269
+ btn_switch.click(on_switch, inputs=[state_pool, state_idx, state_show],
270
+ outputs=[out, state_pool, state_idx, state_show, btn_switch, btn_speech, btn_back, btn_again, user_text])
271
+
272
+ btn_speech.click(on_show_speech, inputs=[state_pool, state_idx, state_show],
273
+ outputs=[out, state_pool, state_idx, state_show, btn_switch, btn_speech, btn_back, btn_again, user_text])
274
+
275
+ btn_back.click(on_back, inputs=[state_pool, state_idx, state_show],
276
+ outputs=[out, state_pool, state_idx, state_show, btn_switch, btn_speech, btn_back, btn_again, user_text])
277
+
278
+ btn_again.click(on_again, inputs=[state_pool, state_idx, state_show],
279
+ outputs=[out, state_pool, state_idx, state_show, btn_switch, btn_speech, btn_back, btn_again, user_text])
280
+
281
+ if __name__ == "__main__":
282
+ demo.launch()