| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- """Company workflow: unique exact user identifiers can bypass confirmation."""
- from typing import TypedDict
- from langchain_core.runnables import RunnableConfig
- from langgraph.checkpoint.memory import InMemorySaver
- from langgraph.graph import END, START, StateGraph
- from langgraph.types import interrupt
- from .qcc import QccError, nonempty
- from .persona import DIRECT_RESPONSES
- from .dialogue import collect_history, model_context
- from .timing import measured
- MORE_COMPANIES_KEY = 'action:search_more_companies'
- class AgentState(TypedDict, total=False):
- question: str
- original_question: str
- conversation_kind: str
- cipa: bool
- needs_company: bool | None
- company_hint: str
- page: int
- searches: int
- search_result: dict
- company_search_provider: str
- selected_company: dict | None
- profile: dict | None
- honors: dict | None
- compang_info: dict
- errors: list[str]
- route: str
- handoff: dict
- response: str
- direct_response: str
- search_plan: dict
- retrieval: dict
- recommendation: dict
- conversation_history: list[dict]
- conversation_context: dict
- previous_company: dict | None
- context_relation: str
- context_query: str
- attachments: list[dict]
- incoming_attachments: list[dict]
- def build_graph(client, analyzer=None, checkpointer=None, retriever=None, recommender=None):
- """Inject analyzer(question)->intent and client for offline tests.
- New questions retain same-thread dialogue, but reuse company facts only explicitly.
- Hosting code must bind thread_id to its authenticated user; this is not an API.
- """
- saver = checkpointer if checkpointer is not None else InMemorySaver()
- def prepare(state, config: RunnableConfig):
- question = state.get('question')
- if not nonempty(question):
- raise ValueError('question_required')
- history = collect_history(state, saver, config)
- from src.step4_api.attachments import routing_context
- context = model_context(history)
- attachments = state.get('incoming_attachments', [])
- if attachments:
- context['attachments'] = routing_context(attachments, question)
- previous_company = state.get('previous_company')
- if state.get('selected_company') and state.get('profile'):
- previous_company = {key: state.get(key) for key in ('selected_company', 'profile', 'honors', 'compang_info')}
- elif state.get('company_hint'):
- # A failed/ambiguous switch must not leave an earlier company reusable.
- previous_company = None
- return {'original_question': question, 'conversation_kind': '', 'cipa': False, 'route': '',
- 'attachments': attachments, 'incoming_attachments': [],
- 'conversation_history': history, 'conversation_context': context,
- 'previous_company': previous_company, 'context_relation': 'new_topic', 'context_query': '',
- 'needs_company': None, 'company_hint': '',
- 'page': 1, 'searches': 0, 'search_result': {}, 'company_search_provider': 'dms', 'selected_company': None,
- 'profile': None, 'honors': None, 'compang_info': {}, 'errors': [], 'handoff': {}, 'response': '', 'direct_response': '',
- 'search_plan': {}, 'retrieval': {}, 'recommendation': {}}
- def analyze(state):
- if analyzer is None:
- # --manual is an explicit operator override for business workflows.
- return {'conversation_kind': 'specific_need', 'route': 'clarify'}
- try:
- context = dict(state['conversation_context'])
- previous_company = state.get('previous_company') or {}
- identity = previous_company.get('selected_company') or {}
- context['confirmed_company'] = {key: identity[key] for key in ('Name', 'CreditCode') if identity.get(key)}
- intent = (analyzer(state['original_question'], conversation_context=context)
- if getattr(analyzer, 'supports_context', False) is True else analyzer(state['original_question']))
- relation = intent.get('context_relation', 'new_topic')
- query = intent.get('context_query', '')
- if relation not in ('follow_up', 'new_topic') or not isinstance(query, str) or len(query) > 500:
- raise ValueError('invalid_context_resolution')
- cipa = intent.get('cipa', False)
- if type(cipa) is not bool:
- raise ValueError('invalid_cipa')
- common = {'context_relation': relation, 'cipa': cipa,
- 'context_query': query.strip() if context.get('attachments') or relation == 'follow_up' and context['turns'] else ''}
- interpretation = intent.get('term_interpretation')
- if interpretation is not None:
- if not isinstance(interpretation, dict):
- raise ValueError('invalid_term_interpretation')
- original, interpreted = interpretation.get('original'), interpretation.get('interpreted')
- confidence = interpretation.get('confidence')
- if (not isinstance(original, str) or not original.strip() or len(original) > 40
- or state['original_question'].count(original) != 1
- or not isinstance(interpreted, str) or not interpreted.strip() or len(interpreted) > 40
- or confidence not in ('high', 'uncertain')
- or original == interpreted
- or original in (intent.get('company_hint') or '')
- or any(ch.isdigit() for ch in original + interpreted)):
- raise ValueError('invalid_term_interpretation')
- if confidence == 'uncertain':
- return {**common, 'cipa': False, 'conversation_kind': 'policy_overview',
- 'needs_company': False, 'route': 'direct',
- 'direct_response': f'您说的“{original}”是指“{interpreted}”吗?也可以直接说想解决什么问题,我再帮您查找。'}
- resolved = state['original_question'].replace(original, interpreted, 1)
- # Preserve the raw question and identity; share only a provisional
- # interpretation across planning, rerank and evidence analysis.
- common['context_query'] = resolved
- common['conversation_context'] = {**state['conversation_context'],
- 'term_interpretation': {'original': original, 'interpreted': interpreted,
- 'query': resolved, 'status': 'hypothesis'}}
- kind = intent['conversation_kind']
- if kind not in ('smalltalk', 'policy_overview', 'specific_need'):
- raise ValueError('invalid_conversation_kind')
- if kind == 'smalltalk' and state.get('attachments'):
- # An attachment-only factual question must reach grounded synthesis.
- kind = 'policy_overview'
- if kind == 'smalltalk':
- reply = intent.get('direct_response')
- valid = isinstance(reply, str) and bool(reply.strip()) and len(reply) <= 4000
- return {**common, 'conversation_kind': kind, 'needs_company': False, 'route': 'direct',
- 'direct_response': reply.strip() if valid else '',
- 'errors': [] if valid else ['direct_response_unavailable']}
- if kind != 'specific_need':
- # Non-chat answers must come from retrieved knowledge, even if the
- # classifier incorrectly supplies an ungrounded direct_response.
- return {**common, 'conversation_kind': kind, 'needs_company': False, 'route': 'handoff'}
- needed, hint = intent['needs_company'], intent.get('company_hint', '')
- if needed is not None and type(needed) is not bool:
- raise ValueError('invalid_intent')
- reuse = intent.get('reuse_company') is True and relation == 'follow_up' and needed is True
- if reuse and identity and (not hint or hint in (identity.get('Name'), identity.get('CreditCode'))):
- return {**common, **previous_company, 'conversation_kind': kind, 'needs_company': True,
- 'company_hint': identity.get('Name', ''), 'route': 'handoff'}
- if not isinstance(hint, str) or (hint and hint not in state['original_question']):
- raise ValueError('invented_company_hint')
- return {**common, 'conversation_kind': kind, 'needs_company': needed, 'company_hint': hint, 'route':
- 'handoff' if needed is False else 'search' if needed is True and hint else 'clarify'}
- except Exception:
- # Never serialize model/network exceptions (may contain request data).
- return {'errors': ['intent_analysis_failed'], 'conversation_kind': 'policy_overview',
- 'needs_company': False, 'route': 'handoff'}
- def direct(state):
- return {'response': state.get('direct_response') or DIRECT_RESPONSES[state['conversation_kind']]}
- def clarify(state):
- answer = interrupt({'kind': 'company_need', 'question': '本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。',
- 'schema': {'needs_company': 'boolean', 'company_hint': 'string'}})
- if not isinstance(answer, dict) or type(answer.get('needs_company')) is not bool:
- return {'route': 'clarify'}
- if answer['needs_company'] is False:
- return {'needs_company': False, 'route': 'handoff'}
- hint = answer.get('company_hint')
- if not nonempty(hint):
- return {'route': 'clarify'}
- return {'needs_company': True, 'company_hint': hint.strip(), 'route': 'search'}
- def search(state):
- if state['searches'] >= 5:
- return {'errors': state['errors'] + ['search_limit'], 'route': 'handoff'}
- try:
- result = (client.search_qcc(state['company_hint'], state['page'])
- if state.get('company_search_provider') == 'qcc' and hasattr(client, 'search_qcc')
- else client.search(state['company_hint'], state['page']))
- common = {'company_search_provider': result.get('provider', state.get('company_search_provider', 'dms')),
- 'errors': state['errors'] + result.get('warnings', []) +
- (['search:' + result['error']] if result.get('error') else [])}
- hint = state['company_hint'].strip()
- matches = [row for row in result['candidates']
- if hint and hint in (row.get('Name'), row.get('CreditCode'))]
- if not result.get('error') and len(matches) == 1 and result.get('exact_unique', True):
- return {**common, 'search_result': result, 'searches': state['searches'] + 1,
- 'selected_company': dict(matches[0]), 'route': 'basic'}
- return {**common, 'search_result': result, 'searches': state['searches'] + 1, 'route': 'confirm'}
- except QccError as exc:
- return {'search_result': {'candidates': [], 'may_have_more': False, 'error': str(exc)},
- 'errors': state['errors'] + ['search:' + str(exc)],
- 'searches': state['searches'] + 1, 'route': 'confirm'}
- def confirm(state):
- result = state['search_result']
- status = 'failed' if result.get('error') else 'found' if result['candidates'] else 'not_found'
- question = '请选择您所指的公司;也可以补充名称重新检索或取消。'
- actions = {'refine': {'keyword': '注册全名或统一社会信用代码'}, 'skip': {}, 'cancel': {}}
- if status == 'not_found':
- question = ('本次未检索到与您提供的名称或代码匹配的企业。请核对并补充工商注册全名或统一社会信用代码;'
- '也可以输入“跳过公司查询”,先按原问题了解相关政策。')
- elif status == 'failed':
- question = ('企业查询服务暂时未能完成查询,目前无法判断是否有匹配企业。'
- '您可以输入/retry重试,或输入“跳过公司查询”,先按原问题了解相关政策。')
- actions['retry'] = {}
- else:
- actions['select'] = {'key_no': '当前候选KeyNo'}
- if result.get('can_search_more'):
- actions['more'] = {}
- question = '请选择您所指的公司;如不在候选中,可选择“搜索更多公司”继续查询。'
- if result.get('may_have_more'):
- actions['next'] = {}
- if result.get('warnings'):
- question = '本地企业资料暂未查全,已尝试通过企查查继续查询。' + question
- display_result = result
- if status == 'found' and result.get('can_search_more'):
- # Reuse the existing candidate UI and selection protocol. This row
- # is an action, never a company fact or a stored search candidate.
- more = {key: None for key in ('CreditCode', 'StartDate', 'OperName', 'Status', 'No', 'Address')}
- more.update(KeyNo=MORE_COMPANIES_KEY, Name='搜索更多公司')
- display_result = {**result, 'candidates': [*result['candidates'], more]}
- answer = interrupt({'kind': 'company_selection' if status == 'found' else 'company_need',
- 'status': status, 'question': question,
- 'result': display_result, 'page': state['page'], 'actions': actions})
- if not isinstance(answer, dict):
- return {'route': 'confirm'}
- action = answer.get('action')
- if action == 'skip':
- return {'needs_company': False, 'route': 'handoff'}
- if action == 'cancel':
- return {'route': 'handoff'}
- if action == 'refine' and nonempty(answer.get('keyword')):
- return {'company_hint': answer['keyword'].strip(), 'page': 1,
- 'company_search_provider': 'dms', 'route': 'search'}
- if action == 'more' and result.get('can_search_more'):
- return {'company_search_provider': 'qcc', 'page': 1, 'route': 'search'}
- if action == 'next' and state['search_result'].get('may_have_more'):
- return {'page': state['page'] + 1, 'route': 'search'}
- if action == 'retry' and state['search_result'].get('error'):
- return {'route': 'search'}
- if action == 'select':
- if answer.get('key_no') == MORE_COMPANIES_KEY and result.get('can_search_more'):
- return {'company_search_provider': 'qcc', 'page': 1, 'route': 'search'}
- for row in state['search_result']['candidates']:
- if row['KeyNo'] == answer.get('key_no'):
- return {'selected_company': dict(row), 'route': 'basic'}
- return {'route': 'confirm'}
- def basic(state):
- try:
- profile = client.basic(state['selected_company'])
- return {'profile': profile, 'route': 'honors',
- 'compang_info': {'company': state['selected_company'],
- 'profile': profile, 'honors': None}}
- except QccError as exc:
- return {'errors': state['errors'] + ['basic:' + str(exc)], 'route': 'handoff'}
- def honors(state):
- result = client.honors(state['selected_company'])
- return {'honors': result,
- 'compang_info': {'company': state['selected_company'],
- 'profile': state['profile'], 'honors': result},
- 'errors': state['errors'] +
- (['honors:' + result['error']] if result['error'] else [])}
- def handoff(state):
- ready = state['needs_company'] is False or state['profile'] is not None
- response = ('需求信息已整理,下一步需检索政策原文并核验适用条件。' if ready else
- '公司信息尚未完整确认或获取,请补充名称后重试;目前无法据此判断企业政策资格。')
- if state['honors'] and not state['honors']['complete']:
- response += '企业荣誉信息未获取完整,不能据此排除其他资质。'
- if retriever is None or recommender is None:
- response += '本次运行未启用政策检索与推荐。'
- return {'handoff': {'original_question': state['original_question'],
- 'attachments': state.get('attachments', []),
- 'conversation_context': state['conversation_context'],
- 'context_relation': state['context_relation'], 'context_query': state['context_query'],
- 'conversation_kind': state['conversation_kind'],
- 'needs_company': state['needs_company'],
- 'company': state['selected_company'], 'profile': state['profile'],
- 'honors': state['honors'], 'errors': state['errors'],
- 'ready_for_retrieval': ready, 'policy_evidence': [],
- 'qualification_status': 'not_evaluated'}, 'response': response}
- def plan(state):
- return {'search_plan': recommender.plan(state['handoff'])}
- def retrieve(state):
- try:
- queries = list(state['search_plan']['queries'])
- if state.get('context_query') and state['context_query'] not in queries:
- queries.insert(0, state['context_query'])
- return {'retrieval': retriever.search(state['original_question'], queries)}
- except Exception:
- return {'retrieval': {'candidates': [], 'queries': [state['original_question']],
- 'complete': False, 'failed_databases': ['retrieval_service'],
- 'content_failures': []}}
- def recommend(state):
- result = recommender.answer({**state['handoff'], 'search_plan': state['search_plan']}, state['retrieval'])
- interpretation = state['conversation_context'].get('term_interpretation')
- if interpretation:
- notice = f'我先按“{interpretation["interpreted"]}”理解您说的“{interpretation["original"]}”;如果不是这个意思,您可以直接纠正。'
- result.setdefault('summary', {}).setdefault('notices', []).insert(0, notice)
- search_plan = state['search_plan']
- result['search_plan'] = search_plan
- if search_plan.get('error'):
- notice = '需求拆解失败,本次仅按原问题检索。'
- result['overview'] += '\n' + notice
- if result.get('summary'):
- result['summary'].setdefault('notices', []).append(notice)
- result['complete'] = result['retrieval_analysis_complete'] = False
- if 'intent_analysis_failed' in state['errors']:
- notice = '需求识别未完成,本次按原问题检索,未推测主体信息。'
- result['overview'] += '\n' + notice
- result.setdefault('summary', {}).setdefault('notices', []).append(notice)
- result['complete'] = False
- return {'recommendation': result, 'response': result['overview']}
- graph = StateGraph(AgentState)
- for name, node in [('prepare', prepare), ('analyze', analyze), ('clarify', clarify),
- ('search', search), ('confirm', confirm), ('basic', basic),
- ('honors', honors), ('handoff', handoff), ('direct', direct)]:
- graph.add_node(name, measured(name)(node))
- graph.add_edge(START, 'prepare')
- graph.add_edge('prepare', 'analyze')
- graph.add_edge('direct', END)
- for name, targets in [('analyze', ['clarify', 'search', 'handoff', 'direct']),
- ('clarify', ['clarify', 'search', 'handoff']),
- ('search', ['confirm', 'basic', 'handoff']),
- ('confirm', ['confirm', 'search', 'basic', 'handoff']),
- ('basic', ['honors', 'handoff'])]:
- graph.add_conditional_edges(name, lambda state: state['route'], {x: x for x in targets})
- graph.add_edge('honors', 'handoff')
- if retriever is not None and recommender is not None:
- graph.add_node('plan', measured('plan')(plan))
- graph.add_node('retrieve', measured('retrieve')(retrieve))
- graph.add_node('recommend', measured('recommend')(recommend))
- graph.add_edge('handoff', 'plan')
- graph.add_edge('plan', 'retrieve')
- graph.add_edge('retrieve', 'recommend')
- graph.add_edge('recommend', END)
- else:
- graph.add_edge('handoff', END)
- return graph.compile(checkpointer=saver)
|