graph.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """Company workflow: unique exact user identifiers can bypass confirmation."""
  2. from typing import TypedDict
  3. from langchain_core.runnables import RunnableConfig
  4. from langgraph.checkpoint.memory import InMemorySaver
  5. from langgraph.graph import END, START, StateGraph
  6. from langgraph.types import interrupt
  7. from .qcc import QccError, nonempty
  8. from .persona import DIRECT_RESPONSES
  9. from .dialogue import collect_history, model_context
  10. from .timing import measured
  11. MORE_COMPANIES_KEY = 'action:search_more_companies'
  12. class AgentState(TypedDict, total=False):
  13. question: str
  14. original_question: str
  15. conversation_kind: str
  16. cipa: bool
  17. needs_company: bool | None
  18. company_hint: str
  19. page: int
  20. searches: int
  21. search_result: dict
  22. company_search_provider: str
  23. selected_company: dict | None
  24. profile: dict | None
  25. honors: dict | None
  26. compang_info: dict
  27. errors: list[str]
  28. route: str
  29. handoff: dict
  30. response: str
  31. direct_response: str
  32. search_plan: dict
  33. retrieval: dict
  34. recommendation: dict
  35. conversation_history: list[dict]
  36. conversation_context: dict
  37. previous_company: dict | None
  38. context_relation: str
  39. context_query: str
  40. attachments: list[dict]
  41. incoming_attachments: list[dict]
  42. def build_graph(client, analyzer=None, checkpointer=None, retriever=None, recommender=None):
  43. """Inject analyzer(question)->intent and client for offline tests.
  44. New questions retain same-thread dialogue, but reuse company facts only explicitly.
  45. Hosting code must bind thread_id to its authenticated user; this is not an API.
  46. """
  47. saver = checkpointer if checkpointer is not None else InMemorySaver()
  48. def prepare(state, config: RunnableConfig):
  49. question = state.get('question')
  50. if not nonempty(question):
  51. raise ValueError('question_required')
  52. history = collect_history(state, saver, config)
  53. from src.step4_api.attachments import routing_context
  54. context = model_context(history)
  55. attachments = state.get('incoming_attachments', [])
  56. if attachments:
  57. context['attachments'] = routing_context(attachments, question)
  58. previous_company = state.get('previous_company')
  59. if state.get('selected_company') and state.get('profile'):
  60. previous_company = {key: state.get(key) for key in ('selected_company', 'profile', 'honors', 'compang_info')}
  61. elif state.get('company_hint'):
  62. # A failed/ambiguous switch must not leave an earlier company reusable.
  63. previous_company = None
  64. return {'original_question': question, 'conversation_kind': '', 'cipa': False, 'route': '',
  65. 'attachments': attachments, 'incoming_attachments': [],
  66. 'conversation_history': history, 'conversation_context': context,
  67. 'previous_company': previous_company, 'context_relation': 'new_topic', 'context_query': '',
  68. 'needs_company': None, 'company_hint': '',
  69. 'page': 1, 'searches': 0, 'search_result': {}, 'company_search_provider': 'dms', 'selected_company': None,
  70. 'profile': None, 'honors': None, 'compang_info': {}, 'errors': [], 'handoff': {}, 'response': '', 'direct_response': '',
  71. 'search_plan': {}, 'retrieval': {}, 'recommendation': {}}
  72. def analyze(state):
  73. if analyzer is None:
  74. # --manual is an explicit operator override for business workflows.
  75. return {'conversation_kind': 'specific_need', 'route': 'clarify'}
  76. try:
  77. context = dict(state['conversation_context'])
  78. previous_company = state.get('previous_company') or {}
  79. identity = previous_company.get('selected_company') or {}
  80. context['confirmed_company'] = {key: identity[key] for key in ('Name', 'CreditCode') if identity.get(key)}
  81. intent = (analyzer(state['original_question'], conversation_context=context)
  82. if getattr(analyzer, 'supports_context', False) is True else analyzer(state['original_question']))
  83. relation = intent.get('context_relation', 'new_topic')
  84. query = intent.get('context_query', '')
  85. if relation not in ('follow_up', 'new_topic') or not isinstance(query, str) or len(query) > 500:
  86. raise ValueError('invalid_context_resolution')
  87. cipa = intent.get('cipa', False)
  88. if type(cipa) is not bool:
  89. raise ValueError('invalid_cipa')
  90. common = {'context_relation': relation, 'cipa': cipa,
  91. 'context_query': query.strip() if context.get('attachments') or relation == 'follow_up' and context['turns'] else ''}
  92. interpretation = intent.get('term_interpretation')
  93. if interpretation is not None:
  94. if not isinstance(interpretation, dict):
  95. raise ValueError('invalid_term_interpretation')
  96. original, interpreted = interpretation.get('original'), interpretation.get('interpreted')
  97. confidence = interpretation.get('confidence')
  98. if (not isinstance(original, str) or not original.strip() or len(original) > 40
  99. or state['original_question'].count(original) != 1
  100. or not isinstance(interpreted, str) or not interpreted.strip() or len(interpreted) > 40
  101. or confidence not in ('high', 'uncertain')
  102. or original == interpreted
  103. or original in (intent.get('company_hint') or '')
  104. or any(ch.isdigit() for ch in original + interpreted)):
  105. raise ValueError('invalid_term_interpretation')
  106. if confidence == 'uncertain':
  107. return {**common, 'cipa': False, 'conversation_kind': 'policy_overview',
  108. 'needs_company': False, 'route': 'direct',
  109. 'direct_response': f'您说的“{original}”是指“{interpreted}”吗?也可以直接说想解决什么问题,我再帮您查找。'}
  110. resolved = state['original_question'].replace(original, interpreted, 1)
  111. # Preserve the raw question and identity; share only a provisional
  112. # interpretation across planning, rerank and evidence analysis.
  113. common['context_query'] = resolved
  114. common['conversation_context'] = {**state['conversation_context'],
  115. 'term_interpretation': {'original': original, 'interpreted': interpreted,
  116. 'query': resolved, 'status': 'hypothesis'}}
  117. kind = intent['conversation_kind']
  118. if kind not in ('smalltalk', 'policy_overview', 'specific_need'):
  119. raise ValueError('invalid_conversation_kind')
  120. if kind == 'smalltalk' and state.get('attachments'):
  121. # An attachment-only factual question must reach grounded synthesis.
  122. kind = 'policy_overview'
  123. if kind == 'smalltalk':
  124. reply = intent.get('direct_response')
  125. valid = isinstance(reply, str) and bool(reply.strip()) and len(reply) <= 4000
  126. return {**common, 'conversation_kind': kind, 'needs_company': False, 'route': 'direct',
  127. 'direct_response': reply.strip() if valid else '',
  128. 'errors': [] if valid else ['direct_response_unavailable']}
  129. if kind != 'specific_need':
  130. # Non-chat answers must come from retrieved knowledge, even if the
  131. # classifier incorrectly supplies an ungrounded direct_response.
  132. return {**common, 'conversation_kind': kind, 'needs_company': False, 'route': 'handoff'}
  133. needed, hint = intent['needs_company'], intent.get('company_hint', '')
  134. if needed is not None and type(needed) is not bool:
  135. raise ValueError('invalid_intent')
  136. reuse = intent.get('reuse_company') is True and relation == 'follow_up' and needed is True
  137. if reuse and identity and (not hint or hint in (identity.get('Name'), identity.get('CreditCode'))):
  138. return {**common, **previous_company, 'conversation_kind': kind, 'needs_company': True,
  139. 'company_hint': identity.get('Name', ''), 'route': 'handoff'}
  140. if not isinstance(hint, str) or (hint and hint not in state['original_question']):
  141. raise ValueError('invented_company_hint')
  142. return {**common, 'conversation_kind': kind, 'needs_company': needed, 'company_hint': hint, 'route':
  143. 'handoff' if needed is False else 'search' if needed is True and hint else 'clarify'}
  144. except Exception:
  145. # Never serialize model/network exceptions (may contain request data).
  146. return {'errors': ['intent_analysis_failed'], 'conversation_kind': 'policy_overview',
  147. 'needs_company': False, 'route': 'handoff'}
  148. def direct(state):
  149. return {'response': state.get('direct_response') or DIRECT_RESPONSES[state['conversation_kind']]}
  150. def clarify(state):
  151. answer = interrupt({'kind': 'company_need', 'question': '本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。',
  152. 'schema': {'needs_company': 'boolean', 'company_hint': 'string'}})
  153. if not isinstance(answer, dict) or type(answer.get('needs_company')) is not bool:
  154. return {'route': 'clarify'}
  155. if answer['needs_company'] is False:
  156. return {'needs_company': False, 'route': 'handoff'}
  157. hint = answer.get('company_hint')
  158. if not nonempty(hint):
  159. return {'route': 'clarify'}
  160. return {'needs_company': True, 'company_hint': hint.strip(), 'route': 'search'}
  161. def search(state):
  162. if state['searches'] >= 5:
  163. return {'errors': state['errors'] + ['search_limit'], 'route': 'handoff'}
  164. try:
  165. result = (client.search_qcc(state['company_hint'], state['page'])
  166. if state.get('company_search_provider') == 'qcc' and hasattr(client, 'search_qcc')
  167. else client.search(state['company_hint'], state['page']))
  168. common = {'company_search_provider': result.get('provider', state.get('company_search_provider', 'dms')),
  169. 'errors': state['errors'] + result.get('warnings', []) +
  170. (['search:' + result['error']] if result.get('error') else [])}
  171. hint = state['company_hint'].strip()
  172. matches = [row for row in result['candidates']
  173. if hint and hint in (row.get('Name'), row.get('CreditCode'))]
  174. if not result.get('error') and len(matches) == 1 and result.get('exact_unique', True):
  175. return {**common, 'search_result': result, 'searches': state['searches'] + 1,
  176. 'selected_company': dict(matches[0]), 'route': 'basic'}
  177. return {**common, 'search_result': result, 'searches': state['searches'] + 1, 'route': 'confirm'}
  178. except QccError as exc:
  179. return {'search_result': {'candidates': [], 'may_have_more': False, 'error': str(exc)},
  180. 'errors': state['errors'] + ['search:' + str(exc)],
  181. 'searches': state['searches'] + 1, 'route': 'confirm'}
  182. def confirm(state):
  183. result = state['search_result']
  184. status = 'failed' if result.get('error') else 'found' if result['candidates'] else 'not_found'
  185. question = '请选择您所指的公司;也可以补充名称重新检索或取消。'
  186. actions = {'refine': {'keyword': '注册全名或统一社会信用代码'}, 'skip': {}, 'cancel': {}}
  187. if status == 'not_found':
  188. question = ('本次未检索到与您提供的名称或代码匹配的企业。请核对并补充工商注册全名或统一社会信用代码;'
  189. '也可以输入“跳过公司查询”,先按原问题了解相关政策。')
  190. elif status == 'failed':
  191. question = ('企业查询服务暂时未能完成查询,目前无法判断是否有匹配企业。'
  192. '您可以输入/retry重试,或输入“跳过公司查询”,先按原问题了解相关政策。')
  193. actions['retry'] = {}
  194. else:
  195. actions['select'] = {'key_no': '当前候选KeyNo'}
  196. if result.get('can_search_more'):
  197. actions['more'] = {}
  198. question = '请选择您所指的公司;如不在候选中,可选择“搜索更多公司”继续查询。'
  199. if result.get('may_have_more'):
  200. actions['next'] = {}
  201. if result.get('warnings'):
  202. question = '本地企业资料暂未查全,已尝试通过企查查继续查询。' + question
  203. display_result = result
  204. if status == 'found' and result.get('can_search_more'):
  205. # Reuse the existing candidate UI and selection protocol. This row
  206. # is an action, never a company fact or a stored search candidate.
  207. more = {key: None for key in ('CreditCode', 'StartDate', 'OperName', 'Status', 'No', 'Address')}
  208. more.update(KeyNo=MORE_COMPANIES_KEY, Name='搜索更多公司')
  209. display_result = {**result, 'candidates': [*result['candidates'], more]}
  210. answer = interrupt({'kind': 'company_selection' if status == 'found' else 'company_need',
  211. 'status': status, 'question': question,
  212. 'result': display_result, 'page': state['page'], 'actions': actions})
  213. if not isinstance(answer, dict):
  214. return {'route': 'confirm'}
  215. action = answer.get('action')
  216. if action == 'skip':
  217. return {'needs_company': False, 'route': 'handoff'}
  218. if action == 'cancel':
  219. return {'route': 'handoff'}
  220. if action == 'refine' and nonempty(answer.get('keyword')):
  221. return {'company_hint': answer['keyword'].strip(), 'page': 1,
  222. 'company_search_provider': 'dms', 'route': 'search'}
  223. if action == 'more' and result.get('can_search_more'):
  224. return {'company_search_provider': 'qcc', 'page': 1, 'route': 'search'}
  225. if action == 'next' and state['search_result'].get('may_have_more'):
  226. return {'page': state['page'] + 1, 'route': 'search'}
  227. if action == 'retry' and state['search_result'].get('error'):
  228. return {'route': 'search'}
  229. if action == 'select':
  230. if answer.get('key_no') == MORE_COMPANIES_KEY and result.get('can_search_more'):
  231. return {'company_search_provider': 'qcc', 'page': 1, 'route': 'search'}
  232. for row in state['search_result']['candidates']:
  233. if row['KeyNo'] == answer.get('key_no'):
  234. return {'selected_company': dict(row), 'route': 'basic'}
  235. return {'route': 'confirm'}
  236. def basic(state):
  237. try:
  238. profile = client.basic(state['selected_company'])
  239. return {'profile': profile, 'route': 'honors',
  240. 'compang_info': {'company': state['selected_company'],
  241. 'profile': profile, 'honors': None}}
  242. except QccError as exc:
  243. return {'errors': state['errors'] + ['basic:' + str(exc)], 'route': 'handoff'}
  244. def honors(state):
  245. result = client.honors(state['selected_company'])
  246. return {'honors': result,
  247. 'compang_info': {'company': state['selected_company'],
  248. 'profile': state['profile'], 'honors': result},
  249. 'errors': state['errors'] +
  250. (['honors:' + result['error']] if result['error'] else [])}
  251. def handoff(state):
  252. ready = state['needs_company'] is False or state['profile'] is not None
  253. response = ('需求信息已整理,下一步需检索政策原文并核验适用条件。' if ready else
  254. '公司信息尚未完整确认或获取,请补充名称后重试;目前无法据此判断企业政策资格。')
  255. if state['honors'] and not state['honors']['complete']:
  256. response += '企业荣誉信息未获取完整,不能据此排除其他资质。'
  257. if retriever is None or recommender is None:
  258. response += '本次运行未启用政策检索与推荐。'
  259. return {'handoff': {'original_question': state['original_question'],
  260. 'attachments': state.get('attachments', []),
  261. 'conversation_context': state['conversation_context'],
  262. 'context_relation': state['context_relation'], 'context_query': state['context_query'],
  263. 'conversation_kind': state['conversation_kind'],
  264. 'needs_company': state['needs_company'],
  265. 'company': state['selected_company'], 'profile': state['profile'],
  266. 'honors': state['honors'], 'errors': state['errors'],
  267. 'ready_for_retrieval': ready, 'policy_evidence': [],
  268. 'qualification_status': 'not_evaluated'}, 'response': response}
  269. def plan(state):
  270. return {'search_plan': recommender.plan(state['handoff'])}
  271. def retrieve(state):
  272. try:
  273. queries = list(state['search_plan']['queries'])
  274. if state.get('context_query') and state['context_query'] not in queries:
  275. queries.insert(0, state['context_query'])
  276. return {'retrieval': retriever.search(state['original_question'], queries)}
  277. except Exception:
  278. return {'retrieval': {'candidates': [], 'queries': [state['original_question']],
  279. 'complete': False, 'failed_databases': ['retrieval_service'],
  280. 'content_failures': []}}
  281. def recommend(state):
  282. result = recommender.answer({**state['handoff'], 'search_plan': state['search_plan']}, state['retrieval'])
  283. interpretation = state['conversation_context'].get('term_interpretation')
  284. if interpretation:
  285. notice = f'我先按“{interpretation["interpreted"]}”理解您说的“{interpretation["original"]}”;如果不是这个意思,您可以直接纠正。'
  286. result.setdefault('summary', {}).setdefault('notices', []).insert(0, notice)
  287. search_plan = state['search_plan']
  288. result['search_plan'] = search_plan
  289. if search_plan.get('error'):
  290. notice = '需求拆解失败,本次仅按原问题检索。'
  291. result['overview'] += '\n' + notice
  292. if result.get('summary'):
  293. result['summary'].setdefault('notices', []).append(notice)
  294. result['complete'] = result['retrieval_analysis_complete'] = False
  295. if 'intent_analysis_failed' in state['errors']:
  296. notice = '需求识别未完成,本次按原问题检索,未推测主体信息。'
  297. result['overview'] += '\n' + notice
  298. result.setdefault('summary', {}).setdefault('notices', []).append(notice)
  299. result['complete'] = False
  300. return {'recommendation': result, 'response': result['overview']}
  301. graph = StateGraph(AgentState)
  302. for name, node in [('prepare', prepare), ('analyze', analyze), ('clarify', clarify),
  303. ('search', search), ('confirm', confirm), ('basic', basic),
  304. ('honors', honors), ('handoff', handoff), ('direct', direct)]:
  305. graph.add_node(name, measured(name)(node))
  306. graph.add_edge(START, 'prepare')
  307. graph.add_edge('prepare', 'analyze')
  308. graph.add_edge('direct', END)
  309. for name, targets in [('analyze', ['clarify', 'search', 'handoff', 'direct']),
  310. ('clarify', ['clarify', 'search', 'handoff']),
  311. ('search', ['confirm', 'basic', 'handoff']),
  312. ('confirm', ['confirm', 'search', 'basic', 'handoff']),
  313. ('basic', ['honors', 'handoff'])]:
  314. graph.add_conditional_edges(name, lambda state: state['route'], {x: x for x in targets})
  315. graph.add_edge('honors', 'handoff')
  316. if retriever is not None and recommender is not None:
  317. graph.add_node('plan', measured('plan')(plan))
  318. graph.add_node('retrieve', measured('retrieve')(retrieve))
  319. graph.add_node('recommend', measured('recommend')(recommend))
  320. graph.add_edge('handoff', 'plan')
  321. graph.add_edge('plan', 'retrieve')
  322. graph.add_edge('retrieve', 'recommend')
  323. graph.add_edge('recommend', END)
  324. else:
  325. graph.add_edge('handoff', END)
  326. return graph.compile(checkpointer=saver)