sofiamak commited on
Commit
5c23104
·
verified ·
1 Parent(s): 3034ae3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +115 -74
app.py CHANGED
@@ -1,35 +1,66 @@
1
- import os, json, html, re
2
  import gradio as gr
3
 
4
- # =============== DeepSeek 客户端(OpenAI 兼容) ===============
5
  _HAS_OPENAI = False
6
  client = None
7
  DEEPSEEK_KEY = os.getenv("DEEPSEEK_API_KEY")
8
  try:
9
  from openai import OpenAI
10
  if DEEPSEEK_KEY:
11
- client = OpenAI(
12
- api_key=DEEPSEEK_KEY,
13
- base_url="https://api.deepseek.com" # ⚠️ 不要带 /v1
14
- )
15
  _HAS_OPENAI = True
16
  except Exception:
17
  _HAS_OPENAI = False
18
- # ============================================================
19
 
20
- APP_TITLE = "孙子你好"
 
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  CHAP_HINT = {
23
- "计篇":"先算后战","作战篇":"速战速决","谋攻篇":"上兵伐谋","军形篇":"先固己势",
24
  "兵势篇":"势能制胜","虚实篇":"避实击虚","军争篇":"抢占先机","九变篇":"因变制胜",
25
  "行军篇":"察情定位","地形篇":"因地用兵","九地篇":"九地九策","火攻篇":"借势放大",
26
- "用间篇":"情报为先","始计篇":"先计而后战"
27
  }
28
 
29
- def render_tip(msg: str):
30
- if not msg: return ""
31
- return f"<div style='color:#9a8fb3;font-size:13px;margin-bottom:6px'>{html.escape(msg)}</div>"
32
-
33
  def render_plan(item, show_speech=False, tip_msg=""):
34
  chap = html.escape(item.get("chapter",""))
35
  hint = html.escape(CHAP_HINT.get(chap,""))
@@ -45,7 +76,7 @@ def render_plan(item, show_speech=False, tip_msg=""):
45
  </div>
46
  """ if (show_speech and speech) else ""
47
 
48
- tip = render_tip(tip_msg)
49
 
50
  return f"""
51
  <div style="font-size:22px;line-height:1.7em;">
@@ -63,8 +94,8 @@ def render_speech_only(item):
63
  speech = html.escape(item.get("speech","")).replace("\n", "<br>")
64
  return f"""
65
  <div style="font-size:22px;line-height:1.8em;padding:20px;background:#fbf8ff;border-radius:12px;border:1px solid #e2d7f2;">
66
- <div style="font-weight:600;margin-bottom:10px;">💬 话术示例:</div>
67
- <div>{speech}</div>
68
  </div>
69
  """
70
 
@@ -81,31 +112,39 @@ def pack_outputs(html_out, pool, idx, show_flag, show_switch, show_speech_btn, s
81
  (gr.update() if user_text_keep else gr.update(value=""))
82
  )
83
 
84
- # ================= DeepSeek 生成(短模板) =================
85
- GEN_SYS = (
86
- "你是中文军师,用《孙子兵法》给出极简、可执行的建议。"
87
- "严格只输出一个 JSON 对象,字段仅限:"
88
- "chapter(篇名,如“虚实篇/谋攻篇/始计篇”等)、"
89
- "quote(贴切原文,保留原句标点)、"
90
- "insight(≤18字,人话,直白精炼)、"
91
- "action(仅1条,≤40字,具体到动作/步骤/策略)、"
92
- "speech(≤80字、可直接对对方说的话;礼貌、委婉、幽默有边界)。"
93
- "不要输出除 JSON 以外的任何内容。"
 
94
  )
95
 
96
- GEN_FEWSHOT = [
97
  {
98
  "role":"user",
99
- "content":"用户问题:老板让我临时加班但我状态很差,如何拒绝更体面?只输出JSON。"
 
 
 
 
 
 
100
  },
101
  {
102
  "role":"assistant",
103
  "content":json.dumps({
 
104
  "chapter":"作战篇",
105
  "quote":"夫兵久而国利者,未之有也。",
106
- "insight":"别打消耗战,用质量说话。",
107
  "action":"给“今晚初稿/明早终版”两选项,让对方决定。",
108
- "speech":"老板,我怕今晚赶出来质量不稳。不如我明早7:30交完整版本,保证更稳,您看可以吗?"
109
  }, ensure_ascii=False)
110
  }
111
  ]
@@ -115,64 +154,68 @@ def _extract_json(text: str) -> dict:
115
  js = m.group(0) if m else text
116
  return json.loads(js)
117
 
118
- def _fallback_item(msg_tip="使用本地兜底(可能未连接API)"):
 
 
 
 
 
 
119
  return {
120
- "item": {
121
- "chapter": "始计篇",
122
- "quote": "先计而后战。",
123
- "insight": "先摸清再出手。",
124
- "action": "列两种可行方案并承诺时间点,让对方选。",
125
- "speech": "我这边按更稳的节奏推进更安全。给您两个方案,咱们选一个最稳的。"
126
- },
127
- "tip": msg_tip
128
  }
129
 
130
- def ai_generate_item(user_text: str, alt: bool=False) -> dict:
131
- # 没 Key 或 SDK 不可用 —— 兜底
 
 
 
 
 
 
 
 
132
  if not _HAS_OPENAI:
133
- fb = _fallback_item("未检测到 DEEPSEEK_API_KEY,已使用本地兜底。")
134
  return fb["item"], fb["tip"]
135
 
136
  try:
137
- user = (
138
- f"用户问题:{user_text}\n"
139
- "请从《孙子兵法》全书挑选最贴切的一句原文,并按上述字段输出。只输出 JSON。"
140
- )
141
- if alt:
142
- user += "\n请更换角度或章节,避免与上一次重复;确保action是全新思路。"
143
-
144
- msgs = [{"role":"system","content":GEN_SYS}] + GEN_FEWSHOT + [{"role":"user","content":user}]
145
-
146
  resp = client.chat.completions.create(
147
  model="deepseek-chat",
148
  messages=msgs,
149
- temperature=0.3 if not alt else 0.6,
150
- max_tokens=480,
151
  timeout=30
152
  )
153
- content = resp.choices[0].message.content.strip()
154
- data = _extract_json(content)
155
- chapter = str(data.get("chapter","始计篇")).strip()
 
 
 
 
 
156
  item = {
157
- "chapter": chapter,
158
- "quote": str(data.get("quote","先计而后战。")).strip(),
159
- "insight": str(data.get("insight","先捋清状况,再出手。")).strip(),
160
- "action": str(data.get("action","给出两种稳妥方案并承诺时间点。")).strip(),
161
  "speech": str(data.get("speech","我们按更稳的节奏推进,彼此都放心。")).strip()
162
  }
163
  return item, ""
164
  except Exception as e:
165
- # 任何异常都不抛出到前端,安静兜底
166
- fb = _fallback_item(f"已使用兜底({type(e).__name__})")
167
  return fb["item"], fb["tip"]
168
 
169
- # ----------------- 回调(沿用按钮逻辑) -----------------
170
  def on_generate(user_text, state_pool, state_idx, state_show):
171
  user_text = (user_text or "").strip()
172
  if not user_text:
173
- return pack_outputs("<div style='font-size:20px;'>请先输入你的问题。</div>",
174
- [], 0, False, False, False, False, False, True)
175
- item, tip = ai_generate_item(user_text, alt=False)
176
  pool = [item]
177
  html_out = render_plan(item, False, tip)
178
  return pack_outputs(html_out, pool, 0, False, True, True, False, True, True)
@@ -180,9 +223,9 @@ def on_generate(user_text, state_pool, state_idx, state_show):
180
  def on_switch(state_pool, state_idx, state_show, user_text):
181
  user_text = (user_text or "").strip()
182
  if not user_text:
183
- return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>",
184
- [], 0, False, False, False, False, False, True)
185
- item, tip = ai_generate_item(user_text, alt=True)
186
  pool = [item]
187
  html_out = render_plan(item, False, tip)
188
  return pack_outputs(html_out, pool, 0, False, True, True, False, True, True)
@@ -190,8 +233,7 @@ def on_switch(state_pool, state_idx, state_show, user_text):
190
  def on_show_speech(state_pool, state_idx, state_show):
191
  pool = state_pool or []
192
  if not pool:
193
- return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>",
194
- pool, state_idx, state_show, False, False, False, False, True)
195
  idx = max(0, min(state_idx, len(pool)-1))
196
  html_out = render_speech_only(pool[idx])
197
  return pack_outputs(html_out, pool, idx, True, True, True, True, True, True)
@@ -199,8 +241,7 @@ def on_show_speech(state_pool, state_idx, state_show):
199
  def on_back(state_pool, state_idx, state_show):
200
  pool = state_pool or []
201
  if not pool:
202
- return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>",
203
- pool, state_idx, state_show, False, False, False, False, True)
204
  idx = max(0, min(state_idx, len(pool)-1))
205
  html_out = render_plan(pool[idx], False, "")
206
  return pack_outputs(html_out, pool, idx, False, True, True, False, True, True)
 
1
+ import os, json, html, re, random
2
  import gradio as gr
3
 
4
+ # ====== 依赖:DeepSeek(OpenAI 兼容) ======
5
  _HAS_OPENAI = False
6
  client = None
7
  DEEPSEEK_KEY = os.getenv("DEEPSEEK_API_KEY")
8
  try:
9
  from openai import OpenAI
10
  if DEEPSEEK_KEY:
11
+ client = OpenAI(api_key=DEEPSEEK_KEY, base_url="https://api.deepseek.com")
 
 
 
12
  _HAS_OPENAI = True
13
  except Exception:
14
  _HAS_OPENAI = False
 
15
 
16
+ # ====== 依赖:中文分词 + TFIDF 相似度 ======
17
+ import jieba
18
+ from sklearn.feature_extraction.text import TfidfVectorizer
19
+ from sklearn.metrics.pairwise import cosine_similarity
20
 
21
+ APP_TITLE = "孙子你好"
22
+ CORPUS_FILE = "sunzi_full.json" # 全书语料(见下方文件格式示例)
23
+ TOP_K = 5 # 交给大模型挑选的候选条目数
24
+
25
+ # ---------------- 读全书 + 建索引 ----------------
26
+ def load_corpus():
27
+ if not os.path.exists(CORPUS_FILE):
28
+ raise RuntimeError("未找到 sunzi_full.json,请先上传全书语料。")
29
+ with open(CORPUS_FILE, "r", encoding="utf-8") as f:
30
+ data = json.load(f)
31
+ # 规范字段
32
+ rows = []
33
+ for i, it in enumerate(data):
34
+ rows.append({
35
+ "id": it.get("id", f"q_{i:04d}"),
36
+ "chapter": it.get("chapter", "").strip(),
37
+ "quote": it.get("quote", "").strip()
38
+ })
39
+ return rows
40
+
41
+ CORPUS = load_corpus()
42
+
43
+ def jieba_tokenizer(s):
44
+ return [w for w in jieba.lcut(s) if w.strip()]
45
+
46
+ DOCS = [f'{r["chapter"]} {r["quote"]}' for r in CORPUS]
47
+ VEC = TfidfVectorizer(tokenizer=jieba_tokenizer, ngram_range=(1,2), min_df=1)
48
+ MATRIX = VEC.fit_transform(DOCS)
49
+
50
+ def topk_candidates(user_text, k=TOP_K):
51
+ qv = VEC.transform([user_text])
52
+ sims = cosine_similarity(qv, MATRIX).ravel()
53
+ idxs = sims.argsort()[::-1][:k]
54
+ return [CORPUS[i] for i in idxs]
55
+
56
+ # ------------- UI 渲染(沿用你的短模板) -------------
57
  CHAP_HINT = {
58
+ "始计篇":"先计而后战","作战篇":"速战速决","谋攻篇":"上兵伐谋","军形篇":"先固己势",
59
  "兵势篇":"势能制胜","虚实篇":"避实击虚","军争篇":"抢占先机","九变篇":"因变制胜",
60
  "行军篇":"察情定位","地形篇":"因地用兵","九地篇":"九地九策","火攻篇":"借势放大",
61
+ "用间篇":"情报为先"
62
  }
63
 
 
 
 
 
64
  def render_plan(item, show_speech=False, tip_msg=""):
65
  chap = html.escape(item.get("chapter",""))
66
  hint = html.escape(CHAP_HINT.get(chap,""))
 
76
  </div>
77
  """ if (show_speech and speech) else ""
78
 
79
+ tip = f"<div style='color:#9a8fb3;font-size:13px;margin-bottom:6px'>{html.escape(tip_msg)}</div>" if tip_msg else ""
80
 
81
  return f"""
82
  <div style="font-size:22px;line-height:1.7em;">
 
94
  speech = html.escape(item.get("speech","")).replace("\n", "<br>")
95
  return f"""
96
  <div style="font-size:22px;line-height:1.8em;padding:20px;background:#fbf8ff;border-radius:12px;border:1px solid #e2d7f2;">
97
+ <div style="font-weight:600;margin-bottom:10px;">💬 话术示例:</div>
98
+ <div>{speech}</div>
99
  </div>
100
  """
101
 
 
112
  (gr.update() if user_text_keep else gr.update(value=""))
113
  )
114
 
115
+ # ------------- DeepSeek 受限生成:仅在 Top-K 中选 -------------
116
+ SYS_LOCKED = (
117
+ "你是中文军师,用《孙子兵法》解决现实问题。"
118
+ "系统会给你一个候选列表(含编号/篇名/原文)。你必须:"
119
+ "1) 只在候选中选出【一条】最贴���原文(不可更换或自造原句);"
120
+ "2) 输出一个 JSON 对象,字段仅限:"
121
+ "id(所选编号)、chapter(篇名)、quote(原文原句)、"
122
+ "insight(≤18字,人话、直白精炼)、"
123
+ "action(仅1条,≤40字、具体到动作/步骤)、"
124
+ "speech(≤80字、可对对方直说;礼貌、委婉、幽默有边界)。"
125
+ "严格禁止输出除 JSON 外的任何内容。"
126
  )
127
 
128
+ FEWSHOT = [
129
  {
130
  "role":"user",
131
+ "content":(
132
+ "【候选】\n"
133
+ "1) 作战篇|夫兵久而国利者,未之有也。\n"
134
+ "2) 虚实篇|避实而击虚。\n"
135
+ "【用户问题】老板让我临时加班但我状态很差,如何拒绝更体面?\n"
136
+ "只输出JSON。"
137
+ )
138
  },
139
  {
140
  "role":"assistant",
141
  "content":json.dumps({
142
+ "id":"1",
143
  "chapter":"作战篇",
144
  "quote":"夫兵久而国利者,未之有也。",
145
+ "insight":"别耗死自己,用质量说服。",
146
  "action":"给“今晚初稿/明早终版”两选项,让对方决定。",
147
+ "speech":"老板,我担心今晚赶出来质量不稳。不如我明早7:30交完整版本,保证更稳,您看行吗?"
148
  }, ensure_ascii=False)
149
  }
150
  ]
 
154
  js = m.group(0) if m else text
155
  return json.loads(js)
156
 
157
+ def _fallback_from_top1(cands, tip="使用本地兜底(可能未连接API)"):
158
+ if not cands:
159
+ return {
160
+ "item": {"chapter":"始计篇","quote":"先计而后战。","insight":"先摸清再出手。","action":"列两种稳妥方案并承诺时间点。","speech":"我们按更稳的节奏推进,彼此都放心。"},
161
+ "tip": tip
162
+ }
163
+ c = cands[0]
164
  return {
165
+ "item": {"chapter": c["chapter"], "quote": c["quote"], "insight":"先捋清状况再出手。","action":"定目标与底线,给一个具体步骤。","speech":"我这边按更稳的节奏推进,给你一个清晰方案。"},
166
+ "tip": tip
 
 
 
 
 
 
167
  }
168
 
169
+ def deepseek_choose_and_write(user_text: str, cands: list, alt: bool=False):
170
+ """
171
+ 把 TF-IDF Top-K 候选喂给 DeepSeek,只允许从中选一条并生成短模板。
172
+ alt=True 时提示换表达,但仍限定在同一候选集。
173
+ """
174
+ listing = "\n".join([f'{i+1}) {c["chapter"]}|{c["quote"]}' for i,c in enumerate(cands)])
175
+ ask = f"【候选】\n{listing}\n【用户问题】{user_text}\n只输出JSON。"
176
+ if alt:
177
+ ask += "\n请换一种表述思路(仍只能在候选内选),避免与上次措辞重复。"
178
+
179
  if not _HAS_OPENAI:
180
+ fb = _fallback_from_top1(cands, "未检测到 DEEPSEEK_API_KEY,已使用本地兜底。")
181
  return fb["item"], fb["tip"]
182
 
183
  try:
184
+ msgs = [{"role":"system","content":SYS_LOCKED}] + FEWSHOT + [{"role":"user","content":ask}]
 
 
 
 
 
 
 
 
185
  resp = client.chat.completions.create(
186
  model="deepseek-chat",
187
  messages=msgs,
188
+ temperature=0.25 if not alt else 0.55,
189
+ max_tokens=420,
190
  timeout=30
191
  )
192
+ data = _extract_json(resp.choices[0].message.content.strip())
193
+ sel_id = str(data.get("id","1")).strip()
194
+ # 把编号对应回候选,确保不越界
195
+ try:
196
+ idx = max(1, min(len(cands), int(re.sub(r"[^0-9]", "", sel_id) or "1"))) - 1
197
+ except Exception:
198
+ idx = 0
199
+ c = cands[idx]
200
  item = {
201
+ "chapter": c["chapter"],
202
+ "quote": c["quote"],
203
+ "insight": str(data.get("insight","先捋清状况再出手。")).strip(),
204
+ "action": str(data.get("action","给出一个具体且可执行的步骤。")).strip(),
205
  "speech": str(data.get("speech","我们按更稳的节奏推进,彼此都放心。")).strip()
206
  }
207
  return item, ""
208
  except Exception as e:
209
+ fb = _fallback_from_top1(cands, f"已使用兜底({type(e).__name__})")
 
210
  return fb["item"], fb["tip"]
211
 
212
+ # ----------------- 回调(维持你的按钮可见逻辑) -----------------
213
  def on_generate(user_text, state_pool, state_idx, state_show):
214
  user_text = (user_text or "").strip()
215
  if not user_text:
216
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题。</div>", [], 0, False, False, False, False, False, True)
217
+ cands = topk_candidates(user_text, TOP_K)
218
+ item, tip = deepseek_choose_and_write(user_text, cands, alt=False)
219
  pool = [item]
220
  html_out = render_plan(item, False, tip)
221
  return pack_outputs(html_out, pool, 0, False, True, True, False, True, True)
 
223
  def on_switch(state_pool, state_idx, state_show, user_text):
224
  user_text = (user_text or "").strip()
225
  if not user_text:
226
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>", [], 0, False, False, False, False, False, True)
227
+ cands = topk_candidates(user_text, TOP_K)
228
+ item, tip = deepseek_choose_and_write(user_text, cands, alt=True)
229
  pool = [item]
230
  html_out = render_plan(item, False, tip)
231
  return pack_outputs(html_out, pool, 0, False, True, True, False, True, True)
 
233
  def on_show_speech(state_pool, state_idx, state_show):
234
  pool = state_pool or []
235
  if not pool:
236
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>", pool, state_idx, state_show, False, False, False, False, True)
 
237
  idx = max(0, min(state_idx, len(pool)-1))
238
  html_out = render_speech_only(pool[idx])
239
  return pack_outputs(html_out, pool, idx, True, True, True, True, True, True)
 
241
  def on_back(state_pool, state_idx, state_show):
242
  pool = state_pool or []
243
  if not pool:
244
+ return pack_outputs("<div style='font-size:20px;'>请先输入你的问题并点击生成兵法。</div>", pool, state_idx, state_show, False, False, False, False, True)
 
245
  idx = max(0, min(state_idx, len(pool)-1))
246
  html_out = render_plan(pool[idx], False, "")
247
  return pack_outputs(html_out, pool, idx, False, True, True, False, True, True)