Files
inquiry_robot/inquiry-agent/agent/policy/multi_text_flow.py
T

770 lines
30 KiB
Python

"""
多段联运私聊:补问、核对、建单、拉群。
本文件职责:一段一段收齐必填后才建一张工单;不去 TMS 查价。
不含空运时,销售点「拉产品进群」后按各段线路把人拉进同一个群,并发群摘要。
含空运时只提示手动拉人到原有群。报价和成交跟进不在本文件。
调用:文字询价 Handler 在 Worker 线程;禁止回调线程同步跑。
禁止:直连 TMS;用两段同一种运输方式建单;把协同字段当成必填。
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Any, Optional
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import (
FlowSession,
ReplyFn,
_session_from_payload,
_session_payload,
build_ledger,
)
from agent.policy.multi_segments import (
Segment,
classify_multi_text,
confirm_text,
group_brief,
includes_air,
merge_supplement,
refresh_segments,
segments_from_payload,
segments_to_payload,
split_segments,
)
from agent.schema.land_options import (
looks_like_confirm,
sample_pair_allowed,
)
logger = logging.getLogger(__name__)
# 只有补问/核对还能续同一张多段草稿。已出号的拉群停点不能拦住新询价。
_FRONT = frozenset({"clarify", "wait_confirm"})
class MultiTextInquiryFlow:
"""多段联运私聊 Owner。一张工单、一个群,段存在书签 quote.multi_segments。"""
bookmark_kind = "multi"
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
self._ledger = ledger or build_ledger()
self._lock = threading.Lock()
self._by_sender: dict[str, FlowSession] = {}
self._by_ticket: dict[str, FlowSession] = {}
self._group_client = group_client
self._bookmark_store = bookmark_store
@property
def ledger(self) -> Any:
return self._ledger
def session_of(self, sender_id: str) -> Optional[FlowSession]:
"""当前销售的多段书签。进程内没有就读 Redis 书签。"""
sid = (sender_id or "").strip()
with self._lock:
hit = self._by_sender.get(sid)
if hit is not None:
return hit
loaded = self._load_bookmark(sid)
if loaded is None:
return None
self._save(loaded)
return loaded
def session_for_action(
self,
sender_id: str,
card_meta: Optional[dict[str, Any]] = None,
*,
text: str = "",
) -> Optional[FlowSession]:
"""
点卡:用工单号找回这一单。HTTP 进程往往没有 Worker 内存书签。
卡上有工单号时,不对上就不拿当前草稿,避免串到别人/最新单。
进程刚重启再从主账收回 MULTI 工单。
"""
sid = (sender_id or "").strip()
if not sid:
return None
no = copy.work_order_from_card_meta(card_meta, text=text)
if no:
with self._lock:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sid:
return hit
sess = self.session_of(sid)
if sess and (sess.business_line or "").upper() == "MULTI":
if not no:
return sess
if (sess.work_order_no or "").strip() == no:
return sess
if no:
return self._load_ticket_session(sid, no)
return None
def on_button(
self,
*,
sender_id: str,
action: str,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
处理多段拉群卡。错工单/未允许直接丢弃,不改主账。
副作用:点「拉产品进群协同」时建一个群并发摘要、各段模板。
调用:卡片 Handler,HTTP 回调线程只入队后由 Worker/本流程执行;本函数会打企微建群。
"""
sess = self.session_for_action(sender_id, card_meta)
card_wo = copy.work_order_from_card_meta(card_meta)
if not sess or action not in (sess.allowed or ()):
logger.info(
"multi card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s",
sender_id,
action,
card_wo or "-",
(sess.work_order_no if sess else "-"),
(sess.phase if sess else "-"),
)
return "card_discard"
if card_wo and sess.work_order_no and card_wo != sess.work_order_no:
logger.warning(
"multi card 工单不一致 card_wo=%s sess_wo=%s,拒绝串单",
card_wo,
sess.work_order_no,
)
return "card_discard"
if action == copy.BTN_PULL_COLLAB:
return self._pull_group(sess, reply)
return "card_discard"
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
"""销售改发单段时丢掉未完成的多段草稿。已出号的仍留在工单号索引。"""
sid = (sender_id or "").strip()
if not sid:
return None
with self._lock:
old = self._by_sender.pop(sid, None)
self._delete_bookmark(sid)
return old
def match_button(self, text: str, sess: Optional[FlowSession]) -> str:
"""拉群按钮。没到可点阶段就不当按钮。"""
if sess is None:
return ""
action = copy.canonical_button(text)
if action == copy.BTN_PULL_COLLAB and action in (sess.allowed or ()):
return action
return ""
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
"""多段还在收字段时不转人工,请销售先把各段发齐。"""
_ = sender_id, text
reply("多段询价请先把各段需求发齐并回复确定。需要人来接管时,请在工单创建后再说。")
return "multi_handoff_later"
def on_text(
self,
*,
sender_id: str,
text: str,
reply: ReplyFn,
injected_facts: Optional[dict[str, Any]] = None,
injected_mode: str = "",
b_result: Optional[dict[str, Any]] = None,
allow_b: bool = False,
chat_fn: Any = None,
inbound_raw: Optional[dict[str, Any]] = None,
) -> str:
"""
处理一条多段私聊。
副作用:写书签、可能建主账工单、点拉群时建企微群。不查 TMS。
新开多段仍按原文切段;补问中的口语句走 injected_* / DeepSeek B,与单聊同一套抽取。
已出号等拉群时,再发一段陆运+海运视为新询价,不能回旧工单的手动拉群提示。
"""
_ = inbound_raw
sid = (sender_id or "").strip()
raw = text or ""
sess = self.session_of(sid)
if sess and self.match_button(raw, sess):
return self._pull_group(sess, reply)
verdict = classify_multi_text(raw)
if verdict == "same_mode":
reply(copy.MULTI_SAME_MODE)
return "reject_same_mode"
if verdict == "multi":
# 补问里再写段1/段2是补字段,不能当新开把已填冲掉。
if sess and (sess.phase or "") == "clarify":
return self._continue_clarify(
sess,
raw,
reply,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
return self._open(
sid,
split_segments(raw).segments,
reply,
immutable=raw,
allow_b=allow_b,
chat_fn=chat_fn,
)
if sess and sess.phase == "wait_confirm" and looks_like_confirm(raw):
return self._create_ticket(sess, reply)
if sess and sess.phase in {"wait_collab", "collab_group"}:
if (sess.collab_chat_id or "").strip():
reply(copy.sea_group_exists(work_order_no=sess.work_order_no))
return "collab_group"
reply(copy.multi_pull_text(work_order_no=sess.work_order_no))
return "wait_collab"
if sess and sess.phase == "wait_manual_group":
reply(copy.multi_air_manual_hint(work_order_no=sess.work_order_no))
return "wait_manual_group"
if sess and sess.phase in _FRONT and not looks_like_confirm(raw):
if sess.phase == "clarify":
return self._continue_clarify(
sess,
raw,
reply,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
if sess.phase == "wait_confirm":
# 核对卡写了「内容有误请重新发」:销售会只改正字段,按补问叠上去。
return self._continue_clarify(
sess,
raw,
reply,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
# 核对卡不再设时效。走到这里说明不是「确定」也不是补字段。
reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。")
return "multi_need_text"
def _open(
self,
sender_id: str,
segments: list[Segment],
reply: ReplyFn,
*,
immutable: str,
allow_b: bool = False,
chat_fn: Any = None,
) -> str:
"""
新的多段原文:缺必填就一次说完,齐了出核对。不建单。
附件口播经常没有「起运港:」这种标签,要按段走和单聊一样的抽取,
不能只认标签行,否则会整段待补充。
"""
filled = self._enrich_segments(segments, allow_b=allow_b, chat_fn=chat_fn)
ready, updated, gaps = refresh_segments(filled)
if not ready:
self._reply_gaps(updated, gaps, reply)
self._save(self._draft(sender_id, updated, phase="clarify", immutable=immutable))
return "clarify"
reply(confirm_text(updated))
self._save(self._draft(sender_id, updated, phase="wait_confirm", immutable=immutable))
return "wait_confirm"
def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None:
"""
补问正文。陆运选项走复制清单,不再另发填写样例图。
副作用:出站文字。只在 Worker 里调。
"""
reply(gaps)
def _enrich_segments(
self,
segments: list[Segment],
*,
allow_b: bool,
chat_fn: Any,
) -> list[Segment]:
"""
各段正文再抽一遍字段。标签行已经在切段时收过,这里只补口语/附件里没写冒号的值。
没有正文就不调模型,避免补问合并后空跑。
"""
if not segments:
return segments
if not allow_b and chat_fn is None:
return segments
from agent.llm.extract_text import extract_inquiry_snapshot
out: list[Segment] = []
for seg in segments:
body = (seg.source or "").strip()
if not body:
out.append(seg)
continue
snap = extract_inquiry_snapshot(
body,
injected_mode=seg.mode,
allow_b=allow_b,
chat_fn=chat_fn,
)
extra = {
str(key): str(val).strip()
for key, val in dict(snap.get("facts") or {}).items()
if str(val or "").strip()
}
subtype = str(snap.get("land_subtype") or "").strip()
if seg.mode == "LAND" and subtype and not str(extra.get("运输类型") or "").strip():
extra["运输类型"] = subtype
facts = dict(seg.facts)
for key, val in extra.items():
if not str(facts.get(key) or "").strip():
facts[key] = val
out.append(
Segment(index=seg.index, mode=seg.mode, facts=facts, source=seg.source)
)
return out
def _continue_clarify(
self,
sess: FlowSession,
text: str,
reply: ReplyFn,
*,
injected_facts: Optional[dict[str, Any]] = None,
injected_mode: str = "",
b_result: Optional[dict[str, Any]] = None,
allow_b: bool = False,
chat_fn: Any = None,
) -> str:
"""
补问中的下一条:能整段重发就替换,否则按段号或唯一缺口段补上。
口语补句走单聊同一套抽取,不在这里猜「广州-深圳」。
"""
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy.system_exception import handle_extract_tech_fail
snap = extract_inquiry_snapshot(
text,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
llm_phase = handle_extract_tech_fail(
snap=snap,
ledger=self._ledger,
reply=reply,
sender_id=sess.sender_id,
sess=sess,
)
if llm_phase:
return llm_phase
extra = {
str(key): str(val).strip()
for key, val in dict(snap.get("facts") or {}).items()
if str(val or "").strip()
}
merged = merge_supplement(self._segments(sess), text, extra_facts=extra)
return self._open(
sess.sender_id,
merged,
reply,
immutable=sess.immutable_text or text,
allow_b=allow_b,
chat_fn=chat_fn,
)
def _restart(self, sender_id: str, text: str, reply: ReplyFn) -> str:
"""核对之后没有回复确定,而是重发内容:整段重来,不在旧核对上改一个字。"""
verdict = classify_multi_text(text)
if verdict == "same_mode":
reply(copy.MULTI_SAME_MODE)
return "reject_same_mode"
if verdict != "multi":
reply("请把多段需求再发一次。")
return "wait_confirm"
return self._open(sender_id, split_segments(text).segments, reply, immutable=text)
def _create_ticket(self, sess: FlowSession, reply: ReplyFn) -> str:
"""回复确定后建一张 MULTI 工单。含空运只给手动拉群提示,不含空运出拉群卡。"""
segments = self._segments(sess)
ready, updated, gaps = refresh_segments(segments)
if not ready:
self._reply_gaps(updated, gaps, reply)
sess.phase = "clarify"
self._put_segments(sess, updated)
self._save(sess)
return "clarify"
payload = segments_to_payload(updated)
created = self._ledger.create_ticket(
sender_id=sess.sender_id,
business_line="MULTI",
facts={"segments_json": json.dumps(payload, ensure_ascii=False)},
idempotency_key=f"multi:{sess.sender_id}:{sess.wait_version}",
)
if not created.get("ok"):
reply("工单创建失败,请稍后再回复确定。")
return "wait_confirm"
no = str(created.get("work_order_no") or "").strip()
sess.work_order_no = no
sess.status = "询价中"
self._put_segments(sess, updated)
if includes_air(updated):
sess.phase = "wait_manual_group"
sess.allowed = ()
self._save(sess)
reply(copy.multi_air_manual_hint(work_order_no=no))
logger.info("multi.created air wo=%s segments=%s", no, len(updated))
return "wait_manual_group"
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_PULL_COLLAB,)
self._save(sess)
reply(copy.multi_pull_text(work_order_no=no), copy.multi_pull_wecom_payload(work_order_no=no))
logger.info("multi.created pull wo=%s segments=%s", no, len(updated))
return "wait_collab"
def _pull_group(self, sess: FlowSession, reply: ReplyFn) -> str:
"""
点拉群:海运段按港口拉海运产品,陆运段按线路拉陆运产品,进同一个群。
任一段没有人,或陆运类型+线路不在填写样例里,不建群,按钮仍可点。
"""
no = (sess.work_order_no or "").strip()
if (sess.collab_chat_id or "").strip():
reply(copy.sea_group_exists(work_order_no=no))
return "collab_group"
segments = self._segments(sess)
if includes_air(segments):
reply(copy.multi_air_manual_hint(work_order_no=no))
return "wait_manual_group"
for seg in segments:
if seg.mode != "LAND":
continue
land_type = str(seg.facts.get("运输类型") or "").strip()
route = str(seg.facts.get("线路类别") or "").strip()
if not sample_pair_allowed(land_type, route):
reply(copy.land_group_pair_rejected())
return sess.phase or "wait_collab"
userids = [sess.sender_id]
product_names: list[str] = []
product_ids: list[str] = []
for seg in segments:
staff = self._staff_for(seg)
if not staff:
reply(copy.land_group_no_staff() if seg.mode == "LAND" else copy.sea_group_no_staff())
return sess.phase or "wait_collab"
for row in staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip() or uid
if not uid:
continue
if uid not in userids:
userids.append(uid)
if uid not in product_ids:
product_ids.append(uid)
product_names.append(name)
if not product_ids:
reply(copy.sea_group_no_staff())
return sess.phase or "wait_collab"
for uid in self._archive_seats():
if uid not in userids:
userids.append(uid)
client = self._client()
from agent.policy.system_exception import call_with_retries
created = call_with_retries(
lambda: client.create_group(name=no, userids=userids) or {},
is_ok=lambda x: bool((x or {}).get("ok") and (x or {}).get("chat_id")),
)
if not created.get("ok") or not created.get("chat_id"):
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
return sess.phase or "wait_collab"
chat_id = str(created.get("chat_id") or "").strip()
sales_name = self._sales_name(sess.sender_id)
binder = getattr(self._ledger, "bind_collab_group", None)
if callable(binder):
binder(
work_order_no=no,
chat_id=chat_id,
member_ids=userids,
product_names=product_names,
product_ids=product_ids,
)
sess.collab_chat_id = chat_id
sess.phase = "collab_group"
sess.allowed = ()
sess.invited_product_names = tuple(product_names)
sess.invited_sales_name = sales_name
self._save(sess)
from agent.policy.sea_group_ops import send_group_text
brief = group_brief(
work_order_no=no,
segments=segments,
sales_name=sales_name,
mention_names=product_names,
)
send_group_text(client, chat_id, brief, mention_userids=product_ids)
ticket = self._ledger.get_ticket(work_order_no=no) if hasattr(self._ledger, "get_ticket") else None
if ticket is not None:
from agent.policy.multi_group_ops import send_segment_templates
send_segment_templates(flow=self, ticket=ticket, chat_id=chat_id)
reply(
copy.sea_group_created(
work_order_no=no,
sales_name=sales_name,
product_names=product_names,
)
)
logger.info("multi.group wo=%s chat=%s products=%s", no, chat_id, product_names)
return "collab_group"
def _staff_for(self, seg: Segment) -> list[dict[str, Any]]:
"""按这一段自己的线路找产品。空运段不在这里拉人。"""
if seg.mode == "SEA":
matcher = getattr(self._ledger, "match_sea_staff", None)
if not callable(matcher):
return []
try:
rows = matcher(
origin=str(seg.facts.get("起运港") or ""),
destination=str(seg.facts.get("目的港") or ""),
) or []
except Exception:
logger.exception("多段匹配海运产品失败")
return []
return [dict(x) for x in rows if isinstance(x, dict)]
if seg.mode == "LAND":
matcher = getattr(self._ledger, "match_land_staff", None)
if not callable(matcher):
return []
try:
rows = matcher(route_category=str(seg.facts.get("线路类别") or "")) or []
except Exception:
logger.exception("多段匹配陆运产品失败")
return []
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def _client(self) -> Any:
"""建群客户端。单测注入;生产与海运共用企微应用客户端。"""
if self._group_client is not None:
return self._group_client
from agent.policy.sea_text_flow import _default_group_client
self._group_client = _default_group_client()
return self._group_client
def _archive_seats(self) -> list[str]:
"""会话存档账号。查本账本,没有也不挡建群。"""
from agent.config import get_settings
from agent.policy.sea_text_flow import ARCHIVE_SEAT_NAMES
ids: list[str] = []
seat = str(getattr(get_settings(), "wecom_archive_seat_user_ids", "") or "").strip()
for part in seat.replace(";", ",").split(","):
uid = part.strip()
if uid and uid not in ids:
ids.append(uid)
finder = getattr(self._ledger, "find_staff_by_name", None)
if not callable(finder):
return ids
for name in ARCHIVE_SEAT_NAMES:
try:
row = finder(name=name) or {}
except Exception:
logger.exception("多段查会话存档账号失败 name=%s", name)
continue
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
if uid and uid not in ids:
ids.append(uid)
return ids
def _sales_name(self, sender_id: str) -> str:
"""群里 @ 的销售名。主账没有姓名时退回 userid。"""
finder = getattr(self._ledger, "get_staff_by_wecom_id", None)
if callable(finder):
try:
row = finder(wecom_id=sender_id) or {}
except Exception:
row = {}
name = str(row.get("name") or "").strip()
if name:
return name
return (sender_id or "").strip() or "销售"
def _draft(self, sender_id: str, segments: list[Segment], *, phase: str, immutable: str) -> FlowSession:
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase=phase,
business_line="MULTI",
immutable_text=immutable,
wait_version=1,
)
self._put_segments(sess, segments)
return sess
def _segments(self, sess: FlowSession) -> list[Segment]:
return segments_from_payload((sess.quote or {}).get("multi_segments"))
def _put_segments(self, sess: FlowSession, segments: list[Segment]) -> None:
quote = dict(sess.quote or {})
quote["multi_segments"] = segments_to_payload(segments)
sess.quote = quote
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
点卡时本进程没有书签:只读主账收回这一张 MULTI 工单。
找不到、不是本人、不是多段:返回 None,绝不改成当前最新单或空运停点。
"""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
logger.warning("multi card 点到 %s 但无主账可读", work_order_no)
return None
view = getter(work_order_no=work_order_no, sender_id=sender_id) or {}
if not view.get("found") or view.get("owned") is False:
logger.warning(
"multi card 点到 %s 主账未收回 found=%s owned=%s",
work_order_no,
view.get("found"),
view.get("owned"),
)
return None
if (str(view.get("business_line") or "")).upper() != "MULTI":
return None
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._save(sess)
logger.info("multi card 从主账收回 wo=%s phase=%s", sess.work_order_no, sess.phase)
return sess
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
"""
主账快照收成多段书签。询价中/被空运误标成 tms_miss 都按等拉群处理。
段列表优先 quote.multi_segments,没有就解 facts.segments_json。
"""
no = str(view.get("work_order_no") or "").strip()
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
raw_segs = quote.get("multi_segments")
if not raw_segs:
blob = facts.get("segments_json") or facts.get("segmentsJson") or ""
if isinstance(blob, str) and blob.strip():
try:
parsed = json.loads(blob)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
quote["multi_segments"] = parsed
raw_segs = parsed
segments = segments_from_payload(raw_segs)
status = str(view.get("status") or "").strip()
chat = str(view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip()
allowed: tuple[str, ...] = ()
if chat:
phase = "collab_group"
elif includes_air(segments):
phase = "wait_manual_group"
elif status in {"已成交", "已关闭"}:
phase = "done"
elif status == "未成交":
phase = "wait_lost_reason"
elif status == "协商中" or phase == "wait_deal":
phase = "wait_deal"
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
# 询价中、tms_miss、wait_collab:销售还没拉群
phase = "wait_collab"
allowed = (copy.BTN_PULL_COLLAB,)
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase=phase,
business_line="MULTI",
facts={str(k): str(v) for k, v in facts.items()},
work_order_no=no,
quote=quote,
status=status,
allowed=allowed,
wait_version=1,
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
collab_chat_id=chat,
)
def _save(self, sess: FlowSession) -> None:
with self._lock:
self._by_sender[sess.sender_id] = sess
no = (sess.work_order_no or "").strip()
if no:
self._by_ticket[no] = sess
self._persist_bookmark(sess)
def _persist_bookmark(self, sess: FlowSession) -> None:
from agent.redis_coord.flow_bookmark import save_flow_bookmark
save_flow_bookmark(
self.bookmark_kind,
sess.sender_id,
_session_payload(sess),
store=self._bookmark_store,
)
def _load_bookmark(self, sender_id: str) -> Optional[FlowSession]:
from agent.redis_coord.flow_bookmark import load_flow_bookmark
raw = load_flow_bookmark(self.bookmark_kind, sender_id, store=self._bookmark_store)
if not raw:
return None
return _session_from_payload(raw)
def _delete_bookmark(self, sender_id: str) -> None:
from agent.redis_coord.flow_bookmark import delete_flow_bookmark
delete_flow_bookmark(self.bookmark_kind, sender_id, store=self._bookmark_store)
_FLOW: Optional[MultiTextInquiryFlow] = None
_FLOW_GUARD = threading.Lock()
def get_multi_text_flow() -> MultiTextInquiryFlow:
"""进程内一份多段流程。单测请自己 new,不要碰这份单例。"""
global _FLOW
with _FLOW_GUARD:
if _FLOW is None:
_FLOW = MultiTextInquiryFlow()
return _FLOW
def reset_multi_text_flow_for_test() -> MultiTextInquiryFlow:
"""单测重置全局实例(强制内存账本)。"""
global _FLOW
from agent.ledger.memory_ledger import MemoryLedger
with _FLOW_GUARD:
_FLOW = MultiTextInquiryFlow(MemoryLedger())
return _FLOW