agent 模式补充文件功能已经实现
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
from .orchestrator import (
|
||||
AgentSession,
|
||||
AgentState,
|
||||
_emit_agent_event,
|
||||
add_supplement,
|
||||
force_submit,
|
||||
load_agent_state,
|
||||
@@ -22,7 +21,6 @@ from .orchestrator import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_emit_agent_event",
|
||||
"AgentSession",
|
||||
"AgentState",
|
||||
"add_supplement",
|
||||
|
||||
+31
-34
@@ -27,7 +27,6 @@ from typing import Any
|
||||
|
||||
from .. import get_logger
|
||||
from ..doc.llm_extractor import (
|
||||
CACHE_DIR_NAME,
|
||||
build_extraction_user_message,
|
||||
llm_query_text,
|
||||
load_cache,
|
||||
@@ -39,6 +38,7 @@ from ..doc.prompt import (
|
||||
build_travel_info_system_prompt,
|
||||
)
|
||||
from ..doc.validator import validate_extracted_info
|
||||
from ..pipeline_core import save_cache_info
|
||||
|
||||
log = get_logger("agent")
|
||||
|
||||
@@ -126,9 +126,26 @@ def load_agent_state(session_dir: Path) -> AgentSession | None:
|
||||
|
||||
AGENT_EVENT_LOG = "agent_events.log"
|
||||
|
||||
# 去重守卫:记录每个 session 上一次发射的事件类型,防止连续重复发射
|
||||
# key: str(session_dir), value: 上一次的 event_type
|
||||
_last_event_type: dict[str, str] = {}
|
||||
|
||||
|
||||
def _emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
|
||||
"""向 agent_events.log 追加一行 JSON 事件。"""
|
||||
"""向 agent_events.log 追加一行 JSON 事件。
|
||||
|
||||
同一 session 连续发射相同 event_type 时直接抛出 RuntimeError,
|
||||
强制调用方修复重复发射的代码,而非静默掩盖。
|
||||
"""
|
||||
session_key = str(session_dir)
|
||||
prev = _last_event_type.get(session_key)
|
||||
if prev == event_type:
|
||||
raise RuntimeError(
|
||||
f"事件重复发射: session={session_dir.name!r}, event_type={event_type!r}。"
|
||||
f"请检查调用链,确保每个事件类型只发射一次。"
|
||||
)
|
||||
_last_event_type[session_key] = event_type
|
||||
|
||||
event = {"type": event_type, **kwargs}
|
||||
try:
|
||||
event_path = session_dir / AGENT_EVENT_LOG
|
||||
@@ -345,32 +362,16 @@ def run_agent_round(
|
||||
log.info("检测到补充文件,加载上一轮分析结果作为历史上下文")
|
||||
|
||||
try:
|
||||
if session.invoice_type == "travel":
|
||||
should_reanalyze = not cache_map.get("travel_info") or is_supplement
|
||||
if should_reanalyze:
|
||||
session.extracted_info = _do_extraction_with_validation(
|
||||
session_dir, session, previous_analysis=previous_analysis
|
||||
)
|
||||
# 提取后立即写入缓存,后续步骤依赖此数据
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
session.extracted_info = cache_map["travel_info"]
|
||||
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
|
||||
should_reanalyze = not cache_map.get(info_key) or is_supplement
|
||||
if should_reanalyze:
|
||||
session.extracted_info = _do_extraction_with_validation(
|
||||
session_dir, session, previous_analysis=previous_analysis
|
||||
)
|
||||
# 提取后立即写入缓存,后续步骤依赖此数据
|
||||
save_cache_info(session_dir, info_key, session.extracted_info)
|
||||
else:
|
||||
should_reanalyze = not cache_map.get("normal_info") or is_supplement
|
||||
if should_reanalyze:
|
||||
session.extracted_info = _do_extraction_with_validation(
|
||||
session_dir, session, previous_analysis=previous_analysis
|
||||
)
|
||||
# 提取后立即写入缓存,后续步骤依赖此数据
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
session.extracted_info = cache_map["normal_info"]
|
||||
session.extracted_info = cache_map[info_key]
|
||||
|
||||
except Exception as e:
|
||||
session.state = AgentState.ERROR
|
||||
@@ -501,13 +502,9 @@ def process_user_text_supplement(
|
||||
)
|
||||
|
||||
# Step 3: 保存到缓存
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
info_file = "travel_info.json" if session.invoice_type == "travel" else "normal_info.json"
|
||||
info_path = cache_dir / info_file
|
||||
with open(info_path, "w", encoding="utf-8") as f:
|
||||
json.dump(session.extracted_info, f, ensure_ascii=False, indent=2)
|
||||
log.info("已更新 %s", info_file)
|
||||
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
|
||||
save_cache_info(session_dir, info_key, session.extracted_info)
|
||||
log.info("已更新 %s", info_key)
|
||||
|
||||
# Step 4: 重新执行 Agent 校验
|
||||
session.state = AgentState.EXTRACTING
|
||||
|
||||
@@ -84,6 +84,10 @@ def run_bot_web(config: dict[str, Any], work_dir: Path) -> None:
|
||||
cache_map = load_cache(work_dir)
|
||||
travel_info = cache_map.get("travel_info")
|
||||
normal_info = cache_map.get("normal_info")
|
||||
|
||||
if travel_info is None and normal_info is None:
|
||||
raise ValueError(f"缓存中未找到 travel_info 或 normal_info,请先执行发票提取流程。工作目录: {work_dir}")
|
||||
|
||||
run_bot(
|
||||
config,
|
||||
headless=True,
|
||||
|
||||
+18
-7
@@ -32,8 +32,8 @@ def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
|
||||
fill_travel_info(bot, basic_info)
|
||||
|
||||
log.info("填写差旅报销明细...")
|
||||
travel_items = travel_info["reimbursement_details"]
|
||||
add_travel_items(bot, travel_items)
|
||||
details = travel_info["reimbursement_details"]
|
||||
add_travel_items(bot, details)
|
||||
|
||||
log.info("填写差旅报销支付方式...")
|
||||
payment_info = travel_info["payment_methods"]
|
||||
@@ -85,8 +85,14 @@ def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细"""
|
||||
def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细
|
||||
|
||||
Args:
|
||||
bot: 已启动的 BaseBot 实例。
|
||||
details: 报销明细字典(travel_info["reimbursement_details"]),包含
|
||||
transport_fee、hotel_fee、conference_fee 等子字段。
|
||||
"""
|
||||
vehicle_map = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
@@ -99,7 +105,7 @@ def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
|
||||
}
|
||||
|
||||
try:
|
||||
traffic_info = travel_items.get("transport_fee") or []
|
||||
traffic_info = details.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
@@ -126,7 +132,7 @@ def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
hotel_info = travel_items.get("hotel_fee") or []
|
||||
hotel_info = details.get("hotel_fee") or []
|
||||
for item in hotel_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
@@ -151,7 +157,7 @@ def add_travel_items(bot: BaseBot, travel_items: dict[str, Any]) -> None:
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
conference_info = travel_items.get("conference_fee") or []
|
||||
conference_info = details.get("conference_fee") or []
|
||||
for item in conference_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
@@ -225,6 +231,9 @@ def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> Non
|
||||
bot._wait_for('text="增加补助清单"', timeout=5000)
|
||||
bot.page.click("#jzg3", timeout=5000)
|
||||
bot.page.wait_for_timeout(500)
|
||||
# 注意:此处使用直接索引而非 .get(),是故意的设计。
|
||||
# LLM 必须返回 person_name 和 person_id 字段,若缺失则说明数据质量有问题,
|
||||
# 应当立即报错终止流程,而非静默跳过。
|
||||
if info["person_name"] and info["person_name"] != "":
|
||||
bot.page.fill("#seacher", info["person_name"])
|
||||
elif info["person_id"] and info["person_id"] != "":
|
||||
@@ -245,6 +254,8 @@ def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> Non
|
||||
bot.page.fill("#enddate1", format_date(info["end_date"]))
|
||||
bot.page.fill("#trafficdays1", str(info["days"]))
|
||||
bot.page.fill("#fooddays1", str(info["days"]))
|
||||
# 补助标准硬编码:交通补助 80 元/天,伙食补助 100 元/天。
|
||||
# 此为阜阳师范大学现行标准,如需适配其他单位,可改为从 config.json 读取。
|
||||
bot.page.fill("#trafficnorm1", str(80))
|
||||
bot.page.fill("#foodnorm1", str(100))
|
||||
trafficmoney = int(info["days"]) * 80
|
||||
|
||||
+60
-2
@@ -7,12 +7,39 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json"
|
||||
|
||||
# 会话级配置允许覆盖的用户相关字段白名单(含密码)
|
||||
SESSION_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
}
|
||||
)
|
||||
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,缺失字段使用默认值"""
|
||||
# 前端安全的配置字段白名单(不含密码)
|
||||
SAFE_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"username",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
}
|
||||
)
|
||||
|
||||
# 模块级配置缓存
|
||||
_config_cache: dict[str, str | Path] | None = None
|
||||
|
||||
|
||||
def _read_config() -> dict[str, str | Path]:
|
||||
"""读取并合并配置,缺失字段使用默认值"""
|
||||
raw = {}
|
||||
if _CONFIG_PATH.exists():
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
@@ -36,6 +63,37 @@ def load_config() -> dict[str, str | Path]:
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict[str, str | Path]:
|
||||
"""加载并合并配置,使用模块级缓存避免重复读取文件"""
|
||||
global _config_cache
|
||||
if _config_cache is None:
|
||||
_config_cache = _read_config()
|
||||
return dict(_config_cache)
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""清除配置缓存(测试或配置变更时调用)"""
|
||||
global _config_cache
|
||||
_config_cache = None
|
||||
|
||||
|
||||
def load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
"""加载会话配置,合并项目全局配置与会话级配置
|
||||
|
||||
仅允许覆盖用户相关字段(白名单),防止用户上传的 config.json
|
||||
覆盖 sso_login_url、portal_url 等系统级配置。
|
||||
"""
|
||||
config = load_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
session_cfg = json.load(f)
|
||||
for key in SESSION_CONFIG_KEYS:
|
||||
if key in session_cfg:
|
||||
config[key] = session_cfg[key]
|
||||
return config
|
||||
|
||||
|
||||
def get_llm_config() -> dict[str, str]:
|
||||
"""加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值"""
|
||||
return {
|
||||
|
||||
@@ -67,16 +67,3 @@ flowchart TD
|
||||
- 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载
|
||||
- LLM 提取失败时直接报错,无正则回退
|
||||
|
||||
## 变更说明(2026-06-16)
|
||||
|
||||
- `llm_extractor.py` 新增 SSE 流式事件写入:`_emit_llm_stream()` 函数将 LLM 思考过程的 `start`/`reasoning`/`chunk`/`end`/`error` 事件写入 `source_dir/llm_stream.log`,前端通过 SSE 实时展示 AI 思考过程与正式回答
|
||||
- `_llm_query_multimodal()` 新增 `source_dir` 参数,流式接收 `delta` 时同步写入 chunk 事件;同时提取 `additional_kwargs.thinking_delta` 写入 reasoning 事件
|
||||
- `extract_travel_info()` 和 `extract_normal_info()` 在 LLM 调用前后写入 `start`/`end` 事件,异常时写入 `error` 事件
|
||||
|
||||
## 变更说明(2026-06-11)
|
||||
|
||||
- `llm_extractor.py` 新增 `extract_normal_info()`:综合普通发票、支付记录和匹配结果,提取报销说明、发票总数、总金额、支付方式、附件清单,缓存为 `normal_info.json`
|
||||
- `llm_extractor.py` 的 `load_cache()` 扩展支持加载 `normal_info.json`
|
||||
- `prompt.py` 新增 `build_normal_info_system_prompt()`:加载 `normal_info_system.md`
|
||||
- `prompts/` 新增 `normal_info_system.md`:普通发票信息提取的系统提示词
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ def _emit_file_event(source_dir: Path, file_name: str, status: str, **kwargs: An
|
||||
pass
|
||||
|
||||
|
||||
# 支持的文件扩展名
|
||||
|
||||
|
||||
def _get_cache_dir(source_dir: Path) -> Path:
|
||||
"""获取缓存目录路径"""
|
||||
cache_dir = source_dir / CACHE_DIR_NAME
|
||||
|
||||
@@ -154,6 +154,8 @@ def fill_consumable_doc(
|
||||
import win32com.client
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
word = None
|
||||
doc = None
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
@@ -213,9 +215,11 @@ def fill_consumable_doc(
|
||||
|
||||
doc.Save()
|
||||
finally:
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
if doc is not None:
|
||||
doc.Close()
|
||||
finally:
|
||||
if word is not None:
|
||||
word.Quit()
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
return doc_path
|
||||
|
||||
+98
-116
@@ -141,6 +141,58 @@ def _image_to_base64(image_path: Path) -> str:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def _stream_llm_response(
|
||||
llm: Any,
|
||||
messages: list[Any],
|
||||
source_dir: Path | None,
|
||||
reasoning_effort: str,
|
||||
log_label: str = "LLM",
|
||||
) -> str:
|
||||
"""流式调用 LLM 并写入 SSE 事件(供 llm_query_text 和 _llm_query_multimodal 共用)。
|
||||
|
||||
Args:
|
||||
llm: LLM 实例。
|
||||
messages: 消息列表。
|
||||
source_dir: 会话目录(可选,传入时启用 SSE 流式事件写入)。
|
||||
reasoning_effort: 推理努力级别。
|
||||
log_label: 日志标签(用于区分"纯文本"和"多模态")。
|
||||
|
||||
Returns:
|
||||
LLM 响应文本。
|
||||
"""
|
||||
try:
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
|
||||
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
extra_body={"reasoning_effort": reasoning_effort},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "chunk", text=delta)
|
||||
|
||||
thinking = getattr(resp, "additional_kwargs", {}) or {}
|
||||
thinking_delta = thinking.get("thinking_delta", "")
|
||||
if thinking_delta and source_dir:
|
||||
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
|
||||
|
||||
text = "".join(parts)
|
||||
log.info("%s请求完成,响应总长度: %d 字符", log_label, len(text))
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "end", label="分析完成")
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("%s请求失败: %s", log_label, e)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "error", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
def llm_query_text(
|
||||
system_prompt: str,
|
||||
text: str,
|
||||
@@ -176,37 +228,7 @@ def llm_query_text(
|
||||
llm_config["api_base"],
|
||||
)
|
||||
|
||||
try:
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
|
||||
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
extra_body={"reasoning_effort": reasoning_effort},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "chunk", text=delta)
|
||||
|
||||
thinking = getattr(resp, "additional_kwargs", {}) or {}
|
||||
thinking_delta = thinking.get("thinking_delta", "")
|
||||
if thinking_delta and source_dir:
|
||||
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
|
||||
|
||||
text = "".join(parts)
|
||||
log.info("LLM 请求完成,响应总长度: %d 字符", len(text))
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "end", label="分析完成")
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 请求失败: %s", e)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "error", error=str(e))
|
||||
raise
|
||||
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM")
|
||||
|
||||
|
||||
def extract_document(file_path: Path) -> dict[str, Any]:
|
||||
@@ -299,38 +321,7 @@ def _llm_query_multimodal(
|
||||
len(final_blocks),
|
||||
)
|
||||
|
||||
try:
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "start", label="正在分析文件...")
|
||||
|
||||
parts = []
|
||||
for resp in llm.stream_chat(
|
||||
messages,
|
||||
temperature=0.1,
|
||||
extra_body={"reasoning_effort": reasoning_effort},
|
||||
):
|
||||
delta = resp.delta
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "chunk", text=delta)
|
||||
|
||||
thinking = getattr(resp, "additional_kwargs", {}) or {}
|
||||
thinking_delta = thinking.get("thinking_delta", "")
|
||||
if thinking_delta and source_dir:
|
||||
_emit_llm_stream(source_dir, "reasoning", text=thinking_delta)
|
||||
|
||||
text = "".join(parts)
|
||||
log.info("LLM 多模态请求完成,响应总长度: %d 字符", len(text))
|
||||
log.info("LLM 多模态响应: %s", text)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "end", label="分析完成")
|
||||
return text
|
||||
except Exception as e:
|
||||
log.error("LLM 多模态请求失败: %s", e)
|
||||
if source_dir:
|
||||
_emit_llm_stream(source_dir, "error", error=str(e))
|
||||
raise
|
||||
return _stream_llm_response(llm, messages, source_dir, reasoning_effort, log_label="LLM多模态")
|
||||
|
||||
|
||||
def load_cache(source_dir: Path) -> dict[str, Any]:
|
||||
@@ -437,6 +428,48 @@ def build_extraction_user_message(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_info(
|
||||
source_dir: Path | None,
|
||||
system_prompt: str,
|
||||
info_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""通用的信息提取函数:加载缓存、构建消息、调用 LLM 并解析 JSON。
|
||||
|
||||
extract_travel_info 和 extract_normal_info 的公共实现。
|
||||
|
||||
Args:
|
||||
source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。
|
||||
system_prompt: 系统提示词。
|
||||
info_type: 信息类型标签("差旅" 或 "普通发票"),用于日志。
|
||||
|
||||
Returns:
|
||||
LLM 提取的结构化信息字典。
|
||||
"""
|
||||
if not source_dir:
|
||||
log.warning("未提供 source_dir,无法加载缓存数据")
|
||||
return {}
|
||||
|
||||
cache_map = load_cache(source_dir)
|
||||
match_result = load_match_result(source_dir)
|
||||
user_message = build_extraction_user_message(cache_map, match_result)
|
||||
|
||||
log.info("开始构建%s信息提取请求,缓存条目: %d, 匹配结果: %d", info_type, len(cache_map), len(match_result))
|
||||
|
||||
try:
|
||||
response = llm_query_text(
|
||||
system_prompt=system_prompt,
|
||||
text=user_message,
|
||||
reasoning_effort="low",
|
||||
source_dir=source_dir,
|
||||
)
|
||||
result = parse_json_response(response)
|
||||
log.info("LLM %s信息提取成功", info_type)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM %s信息提取失败: %s", info_type, e)
|
||||
raise
|
||||
|
||||
|
||||
def extract_travel_info(
|
||||
source_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -450,35 +483,7 @@ def extract_travel_info(
|
||||
Returns:
|
||||
包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。
|
||||
"""
|
||||
if not source_dir:
|
||||
log.warning("未提供 source_dir,无法加载缓存数据")
|
||||
return {}
|
||||
|
||||
system_prompt = build_travel_info_system_prompt()
|
||||
cache_map = load_cache(source_dir)
|
||||
match_result = load_match_result(source_dir)
|
||||
user_message = build_extraction_user_message(cache_map, match_result)
|
||||
|
||||
log.info(f"user_message: {user_message}")
|
||||
|
||||
try:
|
||||
response = llm_query_text(
|
||||
system_prompt=system_prompt,
|
||||
text=user_message,
|
||||
reasoning_effort="low",
|
||||
source_dir=source_dir,
|
||||
)
|
||||
result = parse_json_response(response)
|
||||
log.info("LLM 差旅信息提取成功")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 差旅信息提取失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 普通发票信息提取
|
||||
# ------------------------------------------------------------------
|
||||
return _extract_info(source_dir, build_travel_info_system_prompt(), "差旅")
|
||||
|
||||
|
||||
def extract_normal_info(
|
||||
@@ -494,30 +499,7 @@ def extract_normal_info(
|
||||
Returns:
|
||||
包含报销说明、发票总数、总金额、支付方式、附件清单等字段的字典。
|
||||
"""
|
||||
if not source_dir:
|
||||
log.warning("未提供 source_dir,无法加载缓存数据")
|
||||
return {}
|
||||
|
||||
system_prompt = build_normal_info_system_prompt()
|
||||
cache_map = load_cache(source_dir)
|
||||
match_result = load_match_result(source_dir)
|
||||
user_message = build_extraction_user_message(cache_map, match_result)
|
||||
|
||||
log.info(f"user_message: {user_message}")
|
||||
|
||||
try:
|
||||
response = llm_query_text(
|
||||
system_prompt=system_prompt,
|
||||
text=user_message,
|
||||
reasoning_effort="low",
|
||||
source_dir=source_dir,
|
||||
)
|
||||
result = parse_json_response(response)
|
||||
log.info("LLM 普通发票信息提取成功")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error("LLM 普通发票信息提取失败: %s", e)
|
||||
raise
|
||||
return _extract_info(source_dir, build_normal_info_system_prompt(), "普通发票")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+25
-12
@@ -227,25 +227,38 @@ def _match_one_to_one(
|
||||
assigned: set[int],
|
||||
result: dict[int, list[int]],
|
||||
) -> None:
|
||||
"""一对一匹配:发票数等于刷卡数,按金额从大到小依次配对"""
|
||||
"""一对一匹配:发票数等于刷卡数,对每张刷卡记录寻找金额最接近的未分配发票"""
|
||||
for card_idx, card in enumerate(cards):
|
||||
if card_idx >= len(invoices):
|
||||
break
|
||||
inv = invoices[card_idx]
|
||||
diff = abs(inv["_amount"] - card["_amount"])
|
||||
card_tol = _relative_tolerance(card["_amount"], tolerance)
|
||||
if diff <= card_tol:
|
||||
assigned.add(card_idx)
|
||||
result[card_idx] = [card_idx]
|
||||
if card_idx in result:
|
||||
continue
|
||||
card_amount = card["_amount"]
|
||||
card_tol = _relative_tolerance(card_amount, tolerance)
|
||||
|
||||
# 在未分配的发票中找金额最接近的
|
||||
best_idx = -1
|
||||
best_diff = float("inf")
|
||||
for idx, inv in enumerate(invoices):
|
||||
if idx in assigned:
|
||||
continue
|
||||
diff = abs(inv["_amount"] - card_amount)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best_idx = idx
|
||||
|
||||
if best_idx >= 0 and best_diff <= card_tol:
|
||||
inv = invoices[best_idx]
|
||||
assigned.add(best_idx)
|
||||
result[card_idx] = [best_idx]
|
||||
log.info(
|
||||
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"↔ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
|
||||
f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
|
||||
)
|
||||
else:
|
||||
elif best_idx >= 0:
|
||||
inv = invoices[best_idx]
|
||||
log.warning(
|
||||
f"[一对一] 金额偏差超出容差: "
|
||||
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
|
||||
f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
f"vs ¥{card_amount:.2f} (差 ¥{best_diff:.2f}, 容差 ¥{card_tol:.2f})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]:
|
||||
|
||||
Args:
|
||||
filepath: PDF 文件路径。
|
||||
dpi: 渲染分辨率(默认 150,平衡质量与速度)。
|
||||
dpi: 渲染分辨率(默认 300,平衡质量与速度)。
|
||||
|
||||
Returns:
|
||||
base64 编码的 JPEG 图片字符串列表(每页一个)。
|
||||
|
||||
@@ -54,12 +54,13 @@ class ValidationReport:
|
||||
def _check_field(
|
||||
data: dict[str, Any],
|
||||
path: list[str],
|
||||
required: bool = True,
|
||||
check_empty: bool = True,
|
||||
custom_check: Any = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""沿路径检查字段是否存在且有效。
|
||||
|
||||
检查字段是否存在于嵌套字典中,可选的检查是否为空字符串或自定义校验。
|
||||
|
||||
Returns:
|
||||
(通过, 字段路径字符串)
|
||||
"""
|
||||
|
||||
+24
-75
@@ -14,9 +14,8 @@
|
||||
Bot 仅负责接收信息并填报,不再承担信息提取职责。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .config import load_config
|
||||
@@ -29,83 +28,35 @@ from .doc.invoice import (
|
||||
from .doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
from .doc.llm_extractor import (
|
||||
CACHE_DIR_NAME,
|
||||
extract_normal_info,
|
||||
extract_travel_info,
|
||||
load_cache,
|
||||
from .pipeline_core import (
|
||||
extract_and_cache_normal_info,
|
||||
extract_and_cache_travel_info,
|
||||
is_travel_invoice,
|
||||
)
|
||||
|
||||
log = get_logger("pipeline")
|
||||
|
||||
|
||||
def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]:
|
||||
"""从缓存目录读取发票数据并按类型分组"""
|
||||
"""从缓存目录读取发票数据并按类型分组
|
||||
|
||||
注意:load_cache 会加载所有 .invoice_cache/*.json,包括 travel_info 和 normal_info
|
||||
等提取结果缓存(它们没有 invoice_type 字段),需要显式过滤掉。
|
||||
"""
|
||||
from .doc.llm_extractor import load_cache
|
||||
|
||||
cache_map = load_cache(cache_path)
|
||||
invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")]
|
||||
# 过滤掉非发票的缓存条目:travel_info、normal_info 等提取结果
|
||||
invoices = [
|
||||
data
|
||||
for key, data in cache_map.items()
|
||||
if key not in ("travel_info", "normal_info")
|
||||
and isinstance(data, dict)
|
||||
and data.get("invoice_type") not in ("application", "payment")
|
||||
]
|
||||
return classify_invoice_batch(invoices)
|
||||
|
||||
|
||||
def _extract_travel_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
|
||||
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
|
||||
|
||||
Returns:
|
||||
差旅信息字典,非差旅时返回 None。
|
||||
"""
|
||||
if not groups.get("travel"):
|
||||
return None
|
||||
|
||||
from .doc.llm_extractor import CACHE_DIR_NAME, load_cache
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
if cache_map.get("travel_info"):
|
||||
log.info("使用已有差旅信息缓存")
|
||||
return cast(dict[str, Any] | None, cache_map["travel_info"])
|
||||
|
||||
log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=cache_path)
|
||||
|
||||
# 保存到缓存
|
||||
cache_dir = cache_path / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
log.info("差旅信息已保存到缓存")
|
||||
return travel_info
|
||||
|
||||
|
||||
def _extract_normal_info_if_needed(groups: dict[str, list[dict[str, Any]]], cache_path: Path) -> dict[str, Any] | None:
|
||||
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
|
||||
|
||||
Returns:
|
||||
普通报销信息字典,非普通时返回 None。
|
||||
"""
|
||||
if not groups.get("general"):
|
||||
return None
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
if cache_map.get("normal_info"):
|
||||
log.info("使用已有普通发票信息缓存")
|
||||
return cast(dict[str, Any] | None, cache_map["normal_info"])
|
||||
|
||||
log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=cache_path)
|
||||
|
||||
# 保存到缓存
|
||||
cache_dir = cache_path / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
log.info("普通发票信息已保存到缓存")
|
||||
return normal_info
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
step: str = "all",
|
||||
username: str | None = None,
|
||||
@@ -156,11 +107,10 @@ def run_pipeline(
|
||||
log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张")
|
||||
|
||||
# 发票提取完成后立即判断类型
|
||||
is_travel = bool(groups["travel"]) and not bool(groups["general"])
|
||||
if is_travel:
|
||||
travel_info = _extract_travel_info_if_needed(groups, cache_path)
|
||||
if is_travel_invoice(groups):
|
||||
travel_info = extract_and_cache_travel_info(groups, cache_path)
|
||||
else:
|
||||
normal_info = _extract_normal_info_if_needed(groups, cache_path)
|
||||
normal_info = extract_and_cache_normal_info(groups, cache_path)
|
||||
|
||||
if step == "invoice":
|
||||
log.info("[1/2] 发票提取 完成")
|
||||
@@ -179,15 +129,14 @@ def run_pipeline(
|
||||
if groups is None:
|
||||
groups = _classify_from_cache(cache_path)
|
||||
|
||||
is_travel = bool(groups["travel"]) and not bool(groups["general"])
|
||||
if is_travel:
|
||||
if is_travel_invoice(groups):
|
||||
if travel_info is None:
|
||||
travel_info = _extract_travel_info_if_needed(groups, cache_path)
|
||||
travel_info = extract_and_cache_travel_info(groups, cache_path)
|
||||
log.info("检测到纯差旅发票,使用差旅报销模式")
|
||||
run_bot(config, work_dir=cache_path, travel_info=travel_info)
|
||||
else:
|
||||
if normal_info is None:
|
||||
normal_info = _extract_normal_info_if_needed(groups, cache_path)
|
||||
normal_info = extract_and_cache_normal_info(groups, cache_path)
|
||||
log.info("检测到普通发票,使用普通报销模式")
|
||||
run_bot(config, work_dir=cache_path, normal_info=normal_info)
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
管道核心逻辑
|
||||
|
||||
抽取 pipeline.py(CLI 管道)和 pipeline_web.py(Web 管道)的公共数据流:
|
||||
发票分类判断 -> 差旅/普通信息提取 -> 缓存读写
|
||||
|
||||
两个入口分别传入不同的目录参数,复用此模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import get_logger
|
||||
from .doc.llm_extractor import (
|
||||
CACHE_DIR_NAME,
|
||||
extract_normal_info,
|
||||
extract_travel_info,
|
||||
load_cache,
|
||||
)
|
||||
|
||||
log = get_logger("pipeline_core")
|
||||
|
||||
|
||||
def is_travel_invoice(groups: dict[str, list[dict[str, Any]]]) -> bool:
|
||||
"""判断是否为纯差旅发票(有差旅发票且无普通发票)。
|
||||
|
||||
注意:系统仅支持「纯差旅」和「普通报销」两种模式。
|
||||
若同时存在差旅发票和普通发票(混合),则视为普通报销模式处理——差旅发票
|
||||
对应的费用仍会在普通报销中按项目填报。若需要严格区分,上游应在发票分类
|
||||
后报错提示用户分开提交。
|
||||
"""
|
||||
return bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
|
||||
|
||||
def save_cache_info(cache_path: Path, info_key: str, info: dict[str, Any]) -> None:
|
||||
"""将提取结果保存到缓存目录
|
||||
|
||||
Args:
|
||||
cache_path: 会话目录路径。
|
||||
info_key: 缓存键名("travel_info" 或 "normal_info")。
|
||||
info: 提取结果字典。
|
||||
"""
|
||||
cache_dir = cache_path / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / f"{info_key}.json", "w", encoding="utf-8") as f:
|
||||
json.dump(info, f, ensure_ascii=False, indent=2)
|
||||
log.info("%s 已保存到缓存", info_key)
|
||||
|
||||
|
||||
def extract_and_cache_travel_info(
|
||||
groups: dict[str, list[dict[str, Any]]],
|
||||
cache_path: Path,
|
||||
) -> dict[str, Any] | None:
|
||||
"""当存在差旅发票时,调用 LLM 提取差旅信息并缓存。
|
||||
|
||||
Returns:
|
||||
差旅信息字典,非差旅时返回 None。
|
||||
"""
|
||||
if not groups.get("travel"):
|
||||
return None
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
travel_info = cache_map.get("travel_info")
|
||||
if travel_info:
|
||||
log.info("使用已有差旅信息缓存")
|
||||
return travel_info # type: ignore[no-any-return]
|
||||
|
||||
log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=cache_path)
|
||||
save_cache_info(cache_path, "travel_info", travel_info)
|
||||
return travel_info
|
||||
|
||||
|
||||
def extract_and_cache_normal_info(
|
||||
groups: dict[str, list[dict[str, Any]]],
|
||||
cache_path: Path,
|
||||
) -> dict[str, Any] | None:
|
||||
"""当存在普通发票时,调用 LLM 提取普通报销信息并缓存。
|
||||
|
||||
Returns:
|
||||
普通报销信息字典,非普通时返回 None。
|
||||
"""
|
||||
if not groups.get("general"):
|
||||
return None
|
||||
|
||||
# 检查缓存是否已有
|
||||
cache_map = load_cache(cache_path)
|
||||
normal_info = cache_map.get("normal_info")
|
||||
if normal_info:
|
||||
log.info("使用已有普通发票信息缓存")
|
||||
return normal_info # type: ignore[no-any-return]
|
||||
|
||||
log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=cache_path)
|
||||
save_cache_info(cache_path, "normal_info", normal_info)
|
||||
return normal_info
|
||||
+32
-45
@@ -16,7 +16,6 @@ from urllib.parse import quote
|
||||
|
||||
# 延迟导入,避免循环引用
|
||||
from src import get_logger # noqa: F401
|
||||
from src.config import load_config as load_project_config
|
||||
from src.doc.fill_consumable_doc import (
|
||||
CONSUMABLE_DOC_FILENAME,
|
||||
fill_consumable_from_template,
|
||||
@@ -28,6 +27,11 @@ from src.doc.invoice import (
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
from src.pipeline_core import (
|
||||
extract_and_cache_normal_info,
|
||||
extract_and_cache_travel_info,
|
||||
is_travel_invoice,
|
||||
)
|
||||
|
||||
fill_log = get_logger("fill_consumable_doc")
|
||||
|
||||
@@ -37,9 +41,16 @@ INVOICE_GROUPS_FILE = "invoice_groups.json"
|
||||
|
||||
|
||||
def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]]]) -> None:
|
||||
"""保存发票分类结果到 session 目录的 JSON 文件"""
|
||||
"""保存发票分类结果到 session 目录的 JSON 文件
|
||||
|
||||
保存完整发票分组数据(供 is_travel_invoice/extract_and_cache_* 使用),
|
||||
同时保留计数字段(供快速统计使用)。
|
||||
"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
data = {
|
||||
"travel": groups.get("travel", []),
|
||||
"general": groups.get("general", []),
|
||||
"application": groups.get("application", []),
|
||||
"travel_count": len(groups.get("travel", [])),
|
||||
"general_count": len(groups.get("general", [])),
|
||||
"application_count": len(groups.get("application", [])),
|
||||
@@ -48,28 +59,21 @@ def save_invoice_groups(session_dir: Path, groups: dict[str, list[dict[str, str]
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_invoice_groups(session_dir: Path) -> dict[str, int] | None:
|
||||
"""从 session 目录加载发票分类统计"""
|
||||
def load_invoice_groups(session_dir: Path) -> dict[str, Any] | None:
|
||||
"""从 session 目录加载发票分类结果
|
||||
|
||||
返回包含完整发票分组数据和计数字段的字典。
|
||||
"""
|
||||
groups_path = session_dir / INVOICE_GROUPS_FILE
|
||||
if not groups_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(groups_path, encoding="utf-8") as f:
|
||||
return cast(dict[str, int] | None, json.load(f))
|
||||
return cast(dict[str, Any] | None, json.load(f))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_session_config(session_dir: Path) -> dict[str, Any]:
|
||||
"""加载会话配置,合并项目全局配置与会话级配置"""
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
return config
|
||||
|
||||
|
||||
def resolve_payment_csv(session_dir: Path) -> Path | None:
|
||||
"""查找支付记录 CSV(payment_records.csv)"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
@@ -162,7 +166,7 @@ def append_doc_download(result: dict[str, Any], session_id: str, doc_fill: dict[
|
||||
# ================================================================
|
||||
|
||||
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
def run_pipeline_web(session_dir: Path, config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""在 Web 会话目录中执行发票提取,结果写入 session 目录下的文件
|
||||
|
||||
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||
@@ -192,31 +196,10 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
|
||||
save_invoice_groups(session_dir, groups)
|
||||
|
||||
# ---- Step 2: 差旅/普通信息提取 ----
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
if is_travel:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info, load_cache
|
||||
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("travel_info"):
|
||||
fill_log.info("开始提取差旅信息...")
|
||||
travel_info = extract_travel_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(travel_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("差旅信息已保存到缓存")
|
||||
if is_travel_invoice(groups):
|
||||
extract_and_cache_travel_info(groups, session_dir)
|
||||
else:
|
||||
from src.doc.llm_extractor import CACHE_DIR_NAME, extract_normal_info, load_cache
|
||||
|
||||
cache_map = load_cache(session_dir)
|
||||
if not cache_map.get("normal_info"):
|
||||
fill_log.info("开始提取普通发票信息...")
|
||||
normal_info = extract_normal_info(source_dir=session_dir)
|
||||
cache_dir = session_dir / CACHE_DIR_NAME
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_dir / "normal_info.json", "w", encoding="utf-8") as f:
|
||||
json.dump(normal_info, f, ensure_ascii=False, indent=2)
|
||||
fill_log.info("普通发票信息已保存到缓存")
|
||||
extract_and_cache_normal_info(groups, session_dir)
|
||||
|
||||
# 统计发票总数
|
||||
invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices)
|
||||
@@ -230,16 +213,16 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any
|
||||
"travel_count": len(groups["travel"]),
|
||||
"general_count": len(groups["general"]),
|
||||
}
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config)
|
||||
doc_fill = _try_fill_consumable_doc(session_dir, config or {})
|
||||
append_doc_download(result, session_dir.name, doc_fill)
|
||||
return result
|
||||
|
||||
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
|
||||
def run_financial_submit(session_dir: Path, config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""执行财务系统填报(从前端确认后调用)
|
||||
|
||||
从 invoice_groups.json 读取分类结果,根据发票类型选择填报模式:
|
||||
- 纯差旅发票:差旅报销模式(TODO)
|
||||
- 纯差旅发票:差旅报销模式
|
||||
- 含普通发票:普通报销模式
|
||||
"""
|
||||
csv_path = session_dir / "payment_records.csv"
|
||||
@@ -255,5 +238,9 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str,
|
||||
else:
|
||||
fill_log.info("检测到普通发票,使用普通报销模式")
|
||||
|
||||
run_bot_web(config, session_dir)
|
||||
return {"ok": True}
|
||||
try:
|
||||
run_bot_web(config or {}, session_dir)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
fill_log.error("财务填报失败: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
+229
-324
@@ -16,7 +16,14 @@ from urllib.parse import quote
|
||||
|
||||
from flask import Blueprint, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
from src.config import load_config as load_project_config
|
||||
from src.config import (
|
||||
SAFE_CONFIG_KEYS,
|
||||
SESSION_CONFIG_KEYS,
|
||||
load_session_config,
|
||||
)
|
||||
from src.config import (
|
||||
load_config as load_project_config,
|
||||
)
|
||||
from src.doc.invoice import load_csv, load_invoice_csv
|
||||
|
||||
from . import pipeline_web, sse_handler
|
||||
@@ -51,15 +58,11 @@ def _validate_session(session_id: str) -> Path | tuple[Response, int]:
|
||||
def _build_web_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建配置"""
|
||||
config = load_project_config()
|
||||
for key in (
|
||||
"username",
|
||||
"password",
|
||||
"default_name",
|
||||
"default_card_no",
|
||||
"default_person_id",
|
||||
"consumable_storage",
|
||||
):
|
||||
for key in SESSION_CONFIG_KEYS:
|
||||
if body.get(key):
|
||||
# 基本类型校验:防止非字符串值写入配置
|
||||
if not isinstance(body[key], (str, int, float, bool)):
|
||||
continue
|
||||
config[key] = body[key]
|
||||
return config
|
||||
|
||||
@@ -68,40 +71,32 @@ def _emit_ready_and_submit(
|
||||
session_dir: Path,
|
||||
agent_session: Any,
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Agent 校验通过后,直接触发财务提交(不依赖前端)"""
|
||||
from src.agent import _emit_agent_event
|
||||
) -> dict[str, Any]:
|
||||
"""Agent 校验通过后,直接触发财务提交(不依赖前端)。
|
||||
|
||||
# 发射 agent_ready 事件,前端 SSE 会收到
|
||||
_emit_agent_event(
|
||||
session_dir,
|
||||
"agent_ready",
|
||||
round=agent_session.rounds,
|
||||
message="信息完整,可以提交",
|
||||
)
|
||||
|
||||
# 直接执行财务提交
|
||||
返回 result 字典,由调用方 _run_agent_task 的 finally 块统一写入 result.json。
|
||||
"""
|
||||
# agent_ready 事件已由 run_agent_round 发射,此处仅执行财务提交,避免前端收到重复消息
|
||||
try:
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": True,
|
||||
"round": agent_session.rounds,
|
||||
"message": "信息完整,已自动提交到财务系统",
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": False,
|
||||
"submit_error": submit_result.get("error"),
|
||||
"round": agent_session.rounds,
|
||||
"message": "校验通过但提交失败",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": False,
|
||||
"submit_error": submit_result.get("error"),
|
||||
"round": agent_session.rounds,
|
||||
"message": "校验通过但提交失败",
|
||||
}
|
||||
except Exception as e:
|
||||
result = {
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_ready": True,
|
||||
"submit_ok": False,
|
||||
@@ -110,14 +105,69 @@ def _emit_ready_and_submit(
|
||||
"message": f"校验通过但提交异常: {e}",
|
||||
}
|
||||
|
||||
# 写入 result 文件,SSE done 事件会读取
|
||||
|
||||
def _run_agent_task(
|
||||
session_dir: Path,
|
||||
handler: Any,
|
||||
task_fn: Any,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Agent 任务的通用包装器
|
||||
|
||||
封装重复的闭包结构:清除流日志 -> 执行任务 -> 写入 result.json -> 卸载收集器。
|
||||
|
||||
Args:
|
||||
session_dir: 会话目录
|
||||
handler: SSE 日志收集器句柄
|
||||
task_fn: 业务逻辑回调,接收 (session_dir, config) 返回 (agent_session, result_dict) 或仅 result_dict
|
||||
config: 配置字典(可选)
|
||||
"""
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
# 清除上一轮残留文件,避免 SSE 连接立即读到旧数据
|
||||
for fname in ("llm_stream.log", "agent_events.log", pipeline_web.SESSION_RESULT_FILE):
|
||||
try:
|
||||
(session_dir / fname).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ret = task_fn(session_dir, config)
|
||||
|
||||
# task_fn 返回 (agent_session, result) 或仅 result
|
||||
if isinstance(ret, tuple) and len(ret) == 2:
|
||||
from src.agent import AgentState
|
||||
|
||||
agent_session, result = ret
|
||||
|
||||
if agent_session.state == AgentState.READY:
|
||||
if config is not None:
|
||||
result = _emit_ready_and_submit(session_dir, agent_session, config)
|
||||
elif agent_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": agent_session.state.value,
|
||||
"round": agent_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif agent_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": agent_session.error_message,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
# 统一写入 result.json,SSE 端点检测到后发射 done 事件
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -174,6 +224,9 @@ def upload_file(session_id: str) -> Any:
|
||||
return jsonify({"error": "未选择文件"}), 400
|
||||
|
||||
safe_name = Path(f.filename).name
|
||||
# 安全检查:拒绝包含路径穿越字符的文件名
|
||||
if ".." in safe_name or "/" in safe_name or "\\" in safe_name:
|
||||
return jsonify({"error": "文件名包含非法字符"}), 400
|
||||
f.save(str(session_dir / safe_name))
|
||||
return jsonify({"ok": True, "filename": safe_name})
|
||||
|
||||
@@ -247,7 +300,13 @@ def mobile_upload_file(session_id: str) -> Any:
|
||||
|
||||
@web_bp.route("/api/config/<session_id>", methods=["GET"])
|
||||
def get_session_config(session_id: str) -> Any:
|
||||
"""获取当前会话的配置(供前端回填表单)"""
|
||||
"""获取当前会话的配置(供前端回填表单)
|
||||
|
||||
使用白名单过滤会话配置,防止密码等敏感字段被加载到内存。
|
||||
password 字段始终返回空字符串——密码由前端用户输入,不持久化。
|
||||
注意:当前为 localhost 服务,密码通过 HTTP 明文传输。如需通过代理暴露服务,
|
||||
请启用 HTTPS 或使用反向代理加密。
|
||||
"""
|
||||
session_dir = _validate_session(session_id)
|
||||
if isinstance(session_dir, tuple):
|
||||
return session_dir
|
||||
@@ -255,8 +314,12 @@ def get_session_config(session_id: str) -> Any:
|
||||
config = load_project_config()
|
||||
cfg_path = session_dir / "config.json"
|
||||
if cfg_path.exists():
|
||||
# 仅允许覆盖前端表单字段(不含密码)
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
config.update(json.load(f))
|
||||
session_cfg = json.load(f)
|
||||
for key in SAFE_CONFIG_KEYS:
|
||||
if key in session_cfg:
|
||||
config[key] = session_cfg[key]
|
||||
return jsonify(
|
||||
{
|
||||
"username": config.get("username", ""),
|
||||
@@ -348,6 +411,12 @@ def save_invoice_data(session_id: str) -> Any:
|
||||
|
||||
fieldnames = list(original_rows[0].keys())
|
||||
|
||||
# 校验前端传入的 data 字段是否与原始 CSV 列匹配
|
||||
if data:
|
||||
unknown_keys = set(data[0].keys()) - (set(fieldnames) | {"__row"})
|
||||
if unknown_keys:
|
||||
return jsonify({"error": f"数据包含未知字段: {unknown_keys}"}), 400
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv_module.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
@@ -356,7 +425,7 @@ def save_invoice_data(session_id: str) -> Any:
|
||||
writer.writerow(row)
|
||||
|
||||
resp: dict[str, str | bool | None] = {"ok": True}
|
||||
config = pipeline_web.load_session_config(session_dir)
|
||||
config = load_session_config(session_dir)
|
||||
doc_fill = pipeline_web._try_fill_consumable_doc(session_dir, config)
|
||||
if doc_fill.get("ok"):
|
||||
fn = doc_fill["doc_filename"]
|
||||
@@ -389,31 +458,15 @@ def start_process(session_id: str) -> Any:
|
||||
with open(session_dir / "config.json", "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return pipeline_web.run_pipeline_web(sd, cfg)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
result = pipeline_web.run_pipeline_web(session_dir, config)
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -433,7 +486,7 @@ def stream_logs(session_id: str) -> Any:
|
||||
state = {"agent_size": 0}
|
||||
|
||||
start_time = time.time()
|
||||
timeout = 600
|
||||
timeout = 900 # 略大于 LLM 请求超时 (600s),防止 SSE 先断开
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# 轮询普通日志
|
||||
@@ -523,43 +576,18 @@ def submit_financial(session_id: str) -> Any:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
|
||||
submit_result = pipeline_web.run_financial_submit(sd, cfg)
|
||||
if submit_result.get("ok"):
|
||||
return {"ok": True}
|
||||
return submit_result
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -599,88 +627,52 @@ def agent_process(session_id: str) -> Any:
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
|
||||
from src.agent import (
|
||||
AgentSession,
|
||||
load_agent_state,
|
||||
run_agent_round,
|
||||
save_agent_state,
|
||||
)
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
from src.agent import (
|
||||
AgentSession,
|
||||
AgentState,
|
||||
load_agent_state,
|
||||
run_agent_round,
|
||||
save_agent_state,
|
||||
)
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
agent_session = load_agent_state(sd)
|
||||
if agent_session is None:
|
||||
invoices, applications, groups = extract_invoices(str(sd))
|
||||
if not invoices:
|
||||
return (None, {"ok": False, "error": "未提取到任何发票数据"})
|
||||
|
||||
save_payment_csv(invoices, sd / "payment_records.csv")
|
||||
save_invoice_csv(invoices, sd / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, sd / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(sd, groups)
|
||||
|
||||
from src.pipeline_core import is_travel_invoice
|
||||
|
||||
agent_session = AgentSession(
|
||||
session_id=session_id,
|
||||
invoice_type="travel" if is_travel_invoice(groups) else "normal",
|
||||
)
|
||||
save_agent_state(sd, agent_session)
|
||||
|
||||
agent_session = load_agent_state(session_dir)
|
||||
if agent_session is None:
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
if not invoices:
|
||||
result = {"ok": False, "error": "未提取到任何发票数据"}
|
||||
return
|
||||
agent_session = run_agent_round(sd, agent_session)
|
||||
return (agent_session, {})
|
||||
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(session_dir, groups)
|
||||
|
||||
is_travel = bool(groups.get("travel")) and not bool(groups.get("general"))
|
||||
agent_session = AgentSession(
|
||||
session_id=session_id,
|
||||
invoice_type="travel" if is_travel else "normal",
|
||||
)
|
||||
save_agent_state(session_dir, agent_session)
|
||||
|
||||
agent_session = run_agent_round(session_dir, agent_session)
|
||||
|
||||
if agent_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
_emit_ready_and_submit(session_dir, agent_session, config)
|
||||
elif agent_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": agent_session.state.value,
|
||||
"round": agent_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif agent_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": agent_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -698,7 +690,6 @@ def agent_supplement(session_id: str) -> Any:
|
||||
return jsonify({"error": "未指定补充文件"}), 400
|
||||
|
||||
from src.agent import (
|
||||
AgentState,
|
||||
add_supplement,
|
||||
load_agent_state,
|
||||
run_agent_round,
|
||||
@@ -710,74 +701,42 @@ def agent_supplement(session_id: str) -> Any:
|
||||
|
||||
agent_session = add_supplement(session_dir, agent_session, filenames)
|
||||
|
||||
# 重新提取发票并保存
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(session_dir, groups)
|
||||
|
||||
config_path = session_dir / "config.json"
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
|
||||
new_session = run_agent_round(sd, agent_session, new_files=filenames)
|
||||
return (new_session, {})
|
||||
|
||||
from src.doc.extractor import extract_invoices
|
||||
from src.doc.invoice import (
|
||||
save_application_json,
|
||||
save_invoice_csv,
|
||||
)
|
||||
from src.doc.invoice import (
|
||||
save_csv as save_payment_csv,
|
||||
)
|
||||
|
||||
invoices, applications, groups = extract_invoices(str(session_dir))
|
||||
save_payment_csv(invoices, session_dir / "payment_records.csv")
|
||||
save_invoice_csv(invoices, session_dir / "invoice_summary.csv")
|
||||
|
||||
if applications:
|
||||
save_application_json(applications, session_dir / "travel_applications.json")
|
||||
|
||||
pipeline_web.save_invoice_groups(session_dir, groups)
|
||||
|
||||
new_session = run_agent_round(session_dir, agent_session, new_files=filenames)
|
||||
|
||||
if new_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
config_path = session_dir / "config.json"
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
_emit_ready_and_submit(session_dir, new_session, config)
|
||||
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": new_session.state.value,
|
||||
"round": new_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif new_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": new_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result.get("error") and not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -795,7 +754,6 @@ def agent_user_supplement(session_id: str) -> Any:
|
||||
return jsonify({"error": "请输入补充信息"}), 400
|
||||
|
||||
from src.agent import (
|
||||
AgentState,
|
||||
load_agent_state,
|
||||
process_user_text_supplement,
|
||||
)
|
||||
@@ -804,55 +762,23 @@ def agent_user_supplement(session_id: str) -> Any:
|
||||
if agent_session is None:
|
||||
return jsonify({"error": "未找到 Agent 状态"}), 404
|
||||
|
||||
config_path = session_dir / "config.json"
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
try:
|
||||
(session_dir / "llm_stream.log").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
|
||||
new_session = process_user_text_supplement(sd, agent_session, user_text)
|
||||
return (new_session, {})
|
||||
|
||||
new_session = process_user_text_supplement(session_dir, agent_session, user_text)
|
||||
|
||||
if new_session.state == AgentState.READY:
|
||||
# Agent 校验通过,直接触发财务提交
|
||||
config_path = session_dir / "config.json"
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
_emit_ready_and_submit(session_dir, new_session, config)
|
||||
elif new_session.state == AgentState.AWAITING_SUPPLEMENT:
|
||||
result = {
|
||||
"ok": True,
|
||||
"agent_ready": False,
|
||||
"agent_state": new_session.state.value,
|
||||
"round": new_session.rounds,
|
||||
"waiting_for_supplement": True,
|
||||
}
|
||||
elif new_session.state == AgentState.ERROR:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": new_session.error_message,
|
||||
}
|
||||
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if not result_file.exists():
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@@ -881,37 +807,16 @@ def agent_force_submit(session_id: str) -> Any:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
result_file = session_dir / pipeline_web.SESSION_RESULT_FILE
|
||||
if result_file.exists():
|
||||
result_file.unlink()
|
||||
def _task(sd: Path, cfg: dict[str, Any] | None) -> dict[str, Any]:
|
||||
submit_result = pipeline_web.run_financial_submit(sd, cfg)
|
||||
if submit_result.get("ok"):
|
||||
return {"ok": True}
|
||||
return submit_result
|
||||
|
||||
handler = sse_handler.install_log_collector(session_dir)
|
||||
|
||||
def _run() -> None:
|
||||
result = {"ok": False, "error": "未知错误"}
|
||||
try:
|
||||
submit_result = pipeline_web.run_financial_submit(session_dir, config)
|
||||
if submit_result.get("ok"):
|
||||
result = {"ok": True}
|
||||
else:
|
||||
result = submit_result
|
||||
except BaseException as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
if isinstance(e, KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
tmp_path = session_dir / (pipeline_web.SESSION_RESULT_FILE + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
tmp_path.replace(session_dir / pipeline_web.SESSION_RESULT_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
sse_handler.remove_log_collector(handler)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_agent_task,
|
||||
kwargs={"session_dir": session_dir, "handler": handler, "task_fn": _task, "config": config},
|
||||
daemon=True,
|
||||
).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
@@ -47,10 +47,11 @@ class SSELogHandler(logging.Handler):
|
||||
pass
|
||||
|
||||
def close_file(self) -> None:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def install_log_collector(session_dir: Path) -> SSELogHandler:
|
||||
|
||||
@@ -502,4 +502,91 @@ body {
|
||||
.agent-request-actions .btn {
|
||||
font-size: 13px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
/* ================================================================ */
|
||||
/* 提取结果摘要卡片样式 */
|
||||
/* ================================================================ */
|
||||
|
||||
.extraction-summary {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #ccc;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-label {
|
||||
color: #888;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table th {
|
||||
background: #f5f6f8;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #333;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-table tbody tr:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-attachment-list {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.extraction-summary .summary-attachment-item::marker {
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
}
|
||||
+24
-178
@@ -4,6 +4,7 @@
|
||||
import { App } from './state.js';
|
||||
import { escapeHtml } from './utils.js';
|
||||
import { addChatMessage, showStatus } from './chat.js';
|
||||
import { createSSEConnection, handleDoneResult } from './sse.js';
|
||||
|
||||
// ================================================================
|
||||
// Agent 事件处理
|
||||
@@ -59,82 +60,19 @@ function _handleAgentStateChange(msg) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求补充材料 — 仅更新瞬态状态,持久提示消息由 done 事件分支负责
|
||||
* 请求补充材料 — 将 suggestion 以消息气泡形式单独展示
|
||||
*/
|
||||
export function _handleAgentRequestSupplement(msg) {
|
||||
const suggestion = msg.suggestion || '请补充上传相关材料';
|
||||
showStatus(suggestion);
|
||||
showStatus('信息不完整,请补充材料');
|
||||
addChatMessage(suggestion, 'system');
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息完整 — 已由 process.js 的 done 处理覆盖,此处不再重复显示
|
||||
* 信息完整 — 在消息气泡中提醒用户,最终结果由 done 事件处理
|
||||
*/
|
||||
function _handleAgentReady(msg) {
|
||||
// agent_ready 事件的信息展示统一由 process.js done 分支处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动触发财务系统提交(由 done 事件调用)
|
||||
*/
|
||||
export async function handleAutoSubmit() {
|
||||
if (!App.sessionId) {
|
||||
addChatMessage('请先上传文件并处理', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (App.agentEventSource) {
|
||||
App.agentEventSource.close();
|
||||
App.agentEventSource = null;
|
||||
}
|
||||
|
||||
App.isProcessing = true;
|
||||
App.processState = 'submitting';
|
||||
|
||||
try {
|
||||
showStatus('正在自动提交到财务系统...');
|
||||
|
||||
const response = await fetch(`/api/submit-financial/${App.sessionId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
if (msg.result.submit_ok) {
|
||||
addChatMessage('提交完成!', 'done');
|
||||
} else {
|
||||
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
} else if (result.error) {
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage(`提交失败:${result.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
}
|
||||
addChatMessage(msg.message || '信息完整,正在提交到财务系统', 'done');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,49 +180,15 @@ export async function handleUserSupplement(text) {
|
||||
|
||||
if (result.status === 'started') {
|
||||
showStatus('补充信息已收到,正在重新分析...');
|
||||
App.isProcessing = true;
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
|
||||
if (msg.result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
_handleAgentRequestSupplement(msg.result);
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
if (msg.result.ok) {
|
||||
if (msg.result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志
|
||||
}
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
handleFileProgress: false,
|
||||
handleLLMStream: false,
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
} else if (result.error) {
|
||||
addChatMessage(`处理失败:${result.error}`, 'error');
|
||||
}
|
||||
@@ -328,35 +232,13 @@ export async function handleForceSubmit() {
|
||||
App.isProcessing = true;
|
||||
App.processState = 'submitting';
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
App.forceSubmitting = false;
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
if (msg.result.ok) {
|
||||
addChatMessage('提交完成!', 'done');
|
||||
} else {
|
||||
addChatMessage(`提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
handleFileProgress: false,
|
||||
handleLLMStream: false,
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
App.forceSubmitting = false;
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
App.forceSubmitting = false;
|
||||
@@ -389,49 +271,13 @@ export async function handleSupplementUpload(filenames) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
App.isProcessing = true;
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
|
||||
if (msg.result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
showStatus('信息不完整,请补充上传相关材料');
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
if (msg.result.ok) {
|
||||
// 后端已自动提交,根据 submit_ok 显示结果
|
||||
if (msg.result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${msg.result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`分析失败:${msg.result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志
|
||||
}
|
||||
createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
handleDoneResult(result);
|
||||
},
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断', 'error');
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
addChatMessage(`请求失败:${e.message}`, 'error');
|
||||
|
||||
@@ -123,11 +123,14 @@ function _appendLLMStreamReasoning(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息
|
||||
* 关闭流式气泡(end 阶段)— 气泡保留在聊天历史中作为持久消息。
|
||||
* 如果累积内容是提取结果 JSON,则渲染为美观的摘要卡片。
|
||||
*/
|
||||
function _closeLLMStreamBubble(label) {
|
||||
if (!llmStreamState.bubble) return;
|
||||
|
||||
const accumulated = llmStreamState.accumulated;
|
||||
|
||||
llmStreamState.bubble.classList.remove('processing');
|
||||
llmStreamState.bubble.classList.add('done');
|
||||
|
||||
@@ -141,6 +144,16 @@ function _closeLLMStreamBubble(label) {
|
||||
reasoningSection.open = false;
|
||||
}
|
||||
|
||||
// 尝试将累积文本解析为提取结果 JSON,渲染为摘要卡片
|
||||
const textEl = llmStreamState.bubble.querySelector('.llm-stream-text');
|
||||
if (textEl && accumulated) {
|
||||
const parsed = _tryParseExtractionJson(accumulated);
|
||||
if (parsed) {
|
||||
textEl.innerHTML = '';
|
||||
textEl.appendChild(_buildExtractionSummary(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
llmStreamState = {
|
||||
@@ -196,4 +209,199 @@ function _errorLLMStreamBubble(errorMsg) {
|
||||
reasoningContent: null,
|
||||
reasoningAccumulated: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试将 LLM 输出文本解析为提取结果 JSON
|
||||
* 返回解析后的对象,或 null(非提取结果 JSON)
|
||||
*/
|
||||
function _tryParseExtractionJson(text) {
|
||||
try {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && (parsed.basic_info || parsed.reimbursement_details)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将提取结果 JSON 渲染为美观的摘要卡片 DOM 元素
|
||||
*
|
||||
* @param {Object} data - 提取结果 JSON
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function _buildExtractionSummary(data) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'extraction-summary';
|
||||
|
||||
// 基本信息
|
||||
if (data.basic_info) {
|
||||
const bi = data.basic_info;
|
||||
const section = _summarySection('基本信息');
|
||||
_summaryRow(section, '出差事由', bi.travel_purpose || '');
|
||||
_summaryRow(section, '出差地点', bi.travel_location || '');
|
||||
const dates = bi.start_date && bi.end_date ? `${bi.start_date} 至 ${bi.end_date}` : (bi.start_date || bi.end_date || '');
|
||||
_summaryRow(section, '出差日期', dates);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 交通费
|
||||
if (data.reimbursement_details?.transport_fee?.length) {
|
||||
const section = _summarySection('交通费');
|
||||
const table = _summaryTable(['日期', '出发', '到达', '金额', '备注']);
|
||||
for (const item of data.reimbursement_details.transport_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.start_date || '',
|
||||
item.departure_place || '',
|
||||
item.arrival_place || '',
|
||||
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 住宿费
|
||||
if (data.reimbursement_details?.hotel_fee?.length) {
|
||||
const section = _summarySection('住宿费');
|
||||
const table = _summaryTable(['入住日期', '离店日期', '天数', '发票金额', '报销金额', '备注']);
|
||||
for (const item of data.reimbursement_details.hotel_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.checkin_date || '',
|
||||
item.checkout_date || '',
|
||||
item.days != null ? String(item.days) : '',
|
||||
item.invoice_amount != null ? `¥${item.invoice_amount.toFixed(2)}` : '',
|
||||
item.reimburse_amount != null ? `¥${item.reimburse_amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 会议/培训费
|
||||
if (data.reimbursement_details?.conference_fee?.length) {
|
||||
const section = _summarySection('会议/培训费');
|
||||
const table = _summaryTable(['日期', '金额', '备注']);
|
||||
for (const item of data.reimbursement_details.conference_fee) {
|
||||
_summaryTableRow(table, [
|
||||
item.start_date || '',
|
||||
item.amount != null ? `¥${item.amount.toFixed(2)}` : '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 支付记录
|
||||
if (data.payment_methods?.length) {
|
||||
const section = _summarySection('支付记录');
|
||||
const table = _summaryTable(['日期', '金额', '商户', '备注']);
|
||||
for (const item of data.payment_methods) {
|
||||
_summaryTableRow(table, [
|
||||
item.card_date || '',
|
||||
item.card_amount != null ? `¥${item.card_amount.toFixed(2)}` : '',
|
||||
item.merchant || '',
|
||||
item.remark || '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 补助
|
||||
if (data.subsidy_list?.length) {
|
||||
const section = _summarySection('补助');
|
||||
const table = _summaryTable(['姓名', '开始日期', '结束日期', '天数']);
|
||||
for (const item of data.subsidy_list) {
|
||||
_summaryTableRow(table, [
|
||||
item.person_name || '',
|
||||
item.start_date || '',
|
||||
item.end_date || '',
|
||||
item.days != null ? String(item.days) : '',
|
||||
]);
|
||||
}
|
||||
section.appendChild(table);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// 附件
|
||||
if (data.attachments?.length) {
|
||||
const section = _summarySection('附件');
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'summary-attachment-list';
|
||||
for (const item of data.attachments) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `summary-attachment-item summary-attachment-${item.attachment_type || 'other'}`;
|
||||
li.textContent = `${item.attachment_desc || item.filename || ''}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/* ---- 提取摘要卡片构建辅助函数 ---- */
|
||||
|
||||
function _summarySection(title) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'summary-section';
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'summary-section-title';
|
||||
titleEl.textContent = title;
|
||||
section.appendChild(titleEl);
|
||||
return section;
|
||||
}
|
||||
|
||||
function _summaryRow(section, label, value) {
|
||||
if (!value) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'summary-row';
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'summary-label';
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'summary-value';
|
||||
valueEl.textContent = value;
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(valueEl);
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
function _summaryTable(headers) {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'summary-table';
|
||||
const thead = document.createElement('thead');
|
||||
const tr = document.createElement('tr');
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = h;
|
||||
tr.appendChild(th);
|
||||
}
|
||||
thead.appendChild(tr);
|
||||
table.appendChild(thead);
|
||||
const tbody = document.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
}
|
||||
|
||||
function _summaryTableRow(table, cells) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
const tr = document.createElement('tr');
|
||||
for (const cell of cells) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = cell;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
@@ -39,10 +39,6 @@ export function checkAutoStart() {
|
||||
}
|
||||
}
|
||||
|
||||
if (App.processState === 'awaiting_files') {
|
||||
if (!App.forceStart) return;
|
||||
App.forceStart = false;
|
||||
}
|
||||
|
||||
if (App.processState === 'awaiting_supplement') {
|
||||
return;
|
||||
|
||||
@@ -6,15 +6,9 @@ import { ensureSession } from './utils.js';
|
||||
import {
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
setFileProcessing,
|
||||
setFileDone,
|
||||
setFileCached,
|
||||
setFileError,
|
||||
handleLLMStream,
|
||||
addChatMessage,
|
||||
showStatus,
|
||||
} from './chat.js';
|
||||
import { handleAgentEvent, handleAutoSubmit } from './agent.js';
|
||||
import { createSSEConnection, handleDoneResult } from './sse.js';
|
||||
|
||||
export async function startProcess() {
|
||||
if (!App.allFiles.length) {
|
||||
@@ -31,6 +25,8 @@ export async function startProcess() {
|
||||
await ensureSession();
|
||||
|
||||
for (const f of App.allFiles) {
|
||||
// 跳过服务器同步的文件(已在服务器上)
|
||||
if (f.__source === 'server') continue;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${App.sessionId}`, { method: 'POST', body: fd });
|
||||
@@ -54,89 +50,13 @@ export async function startProcess() {
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
const es = new EventSource(`/api/logs/${App.sessionId}`);
|
||||
App.agentEventSource = es;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === 'file_progress') {
|
||||
const filename = msg.file;
|
||||
switch (msg.status) {
|
||||
case 'processing':
|
||||
setFileProcessing(filename);
|
||||
break;
|
||||
case 'done':
|
||||
setFileDone(filename, msg.summary || {});
|
||||
break;
|
||||
case 'cached':
|
||||
setFileCached(filename);
|
||||
break;
|
||||
case 'error':
|
||||
setFileError(filename, msg.error);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'llm_stream') {
|
||||
handleLLMStream(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
App.agentEventSource = null;
|
||||
removeTypingIndicator();
|
||||
const result = msg.result;
|
||||
App.isProcessing = false;
|
||||
|
||||
if (App.forceSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
showStatus('信息不完整,请补充上传相关材料');
|
||||
addChatMessage('您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验', 'system');
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
|
||||
if (result.ok) {
|
||||
if (result.agent_ready) {
|
||||
if (result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else if (result.invoice_count) {
|
||||
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行
|
||||
}
|
||||
const es = createSSEConnection(App.sessionId, {
|
||||
onDone: (result) => {
|
||||
removeTypingIndicator();
|
||||
handleDoneResult(result);
|
||||
},
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
removeTypingIndicator();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断,请刷新页面重试', 'error');
|
||||
};
|
||||
App.agentEventSource = es;
|
||||
|
||||
} catch (e) {
|
||||
removeTypingIndicator();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* SSE 连接管理
|
||||
*
|
||||
* 封装 EventSource 的创建、事件注册和 done 事件处理,避免重复代码。
|
||||
*/
|
||||
import { App } from './state.js';
|
||||
import {
|
||||
removeTypingIndicator,
|
||||
setFileProcessing,
|
||||
setFileDone,
|
||||
setFileCached,
|
||||
setFileError,
|
||||
handleLLMStream,
|
||||
addChatMessage,
|
||||
showStatus,
|
||||
} from './chat.js';
|
||||
import { handleAgentEvent } from './agent.js';
|
||||
|
||||
/**
|
||||
* 创建 SSE 连接并返回 EventSource 实例
|
||||
*
|
||||
* @param {string} sessionId - 会话 ID
|
||||
* @param {Object} options - 选项配置
|
||||
* @param {Function} options.onDone - done 事件处理器
|
||||
* @param {boolean} options.handleFileProgress - 是否处理文件进度事件 (默认 true)
|
||||
* @param {boolean} options.handleLLMStream - 是否处理 LLM 流式事件 (默认 true)
|
||||
* @param {boolean} options.handleAgentEvents - 是否处理 Agent 事件 (默认 true)
|
||||
* @returns {EventSource}
|
||||
*/
|
||||
export function createSSEConnection(sessionId, options = {}) {
|
||||
const {
|
||||
onDone,
|
||||
handleFileProgress = true,
|
||||
handleLLMStream: handleStream = true,
|
||||
handleAgentEvents = true,
|
||||
} = options;
|
||||
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
// 文件进度事件
|
||||
if (handleFileProgress && msg.type === 'file_progress') {
|
||||
const filename = msg.file;
|
||||
switch (msg.status) {
|
||||
case 'processing':
|
||||
setFileProcessing(filename);
|
||||
break;
|
||||
case 'done':
|
||||
setFileDone(filename, msg.summary || {});
|
||||
break;
|
||||
case 'cached':
|
||||
setFileCached(filename);
|
||||
break;
|
||||
case 'error':
|
||||
setFileError(filename, msg.error);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// LLM 流式事件
|
||||
if (handleStream && msg.type === 'llm_stream') {
|
||||
handleLLMStream(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent 事件
|
||||
if (handleAgentEvents && msg.type && msg.type.startsWith('agent_')) {
|
||||
handleAgentEvent(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 完成事件
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
if (onDone) {
|
||||
onDone(msg.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 普通日志行,忽略
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
removeTypingIndicator();
|
||||
App.isProcessing = false;
|
||||
App.processState = 'done';
|
||||
addChatMessage('连接中断,请刷新页面重试', 'error');
|
||||
};
|
||||
|
||||
return es;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 done 事件的通用逻辑 (根据 result 内容更新 UI 状态)
|
||||
*
|
||||
* @param {Object} result - done 事件的结果对象
|
||||
*/
|
||||
export function handleDoneResult(result) {
|
||||
App.isProcessing = false;
|
||||
|
||||
if (App.forceSubmitting) {
|
||||
App.forceSubmitting = false;
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
if (result.ok) {
|
||||
addChatMessage('提交完成!', 'done');
|
||||
} else {
|
||||
addChatMessage(`提交失败:${result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.waiting_for_supplement) {
|
||||
App.processState = 'awaiting_supplement';
|
||||
showStatus('信息不完整,请补充上传相关材料');
|
||||
addChatMessage(
|
||||
'您可通过上传区补充文件,或直接输入文字说明补充信息,或输入"直接提交"跳过校验',
|
||||
'system'
|
||||
);
|
||||
} else {
|
||||
App.processState = 'done';
|
||||
App.lastProcessedFileCount = App.allFiles.length;
|
||||
|
||||
if (result.ok) {
|
||||
if (result.agent_ready) {
|
||||
if (result.submit_ok !== false) {
|
||||
addChatMessage('信息完整,已自动提交到财务系统', 'done');
|
||||
} else {
|
||||
addChatMessage(`校验通过但提交失败:${result.submit_error || '未知错误'}`, 'error');
|
||||
}
|
||||
} else if (result.invoice_count) {
|
||||
addChatMessage(`处理完成!共提取 ${result.invoice_count} 张发票数据`, 'done');
|
||||
}
|
||||
} else {
|
||||
addChatMessage(`处理失败:${result.error || '未知错误'}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,10 @@ async function syncFiles() {
|
||||
const localNames = new Set(App.allFiles.map(f => f.name));
|
||||
const addedFiles = [];
|
||||
|
||||
// 只同步文件名元数据,不下载文件内容(文件已在服务器上)
|
||||
for (const serverFile of serverFiles) {
|
||||
if (!localNames.has(serverFile.name)) {
|
||||
const resp = await fetch(`/api/download/${App.sessionId}/${encodeURIComponent(serverFile.name)}`);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], serverFile.name, { type: blob.type });
|
||||
file.__source = 'server';
|
||||
const file = { name: serverFile.name, __source: 'server' };
|
||||
App.allFiles.push(file);
|
||||
addedFiles.push(serverFile.name);
|
||||
}
|
||||
|
||||
+29
-37
@@ -9,6 +9,29 @@ import { ensureSession } from './utils.js';
|
||||
|
||||
export async function handleFiles(input) {
|
||||
const files = Array.from(input.files);
|
||||
const result = await _processFiles(files);
|
||||
|
||||
if (result.hasNewFiles && App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
App.newFilenames = result.newFilenames;
|
||||
}
|
||||
if (result.hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
hideAgentRequest();
|
||||
await _uploadNewFiles(result.newFilenames);
|
||||
App.newFilenames = result.newFilenames;
|
||||
App.processState = 'idle';
|
||||
checkAutoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
promptMissingConfig();
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文件处理逻辑(handleFiles 和拖拽共享)
|
||||
*/
|
||||
async function _processFiles(files) {
|
||||
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
|
||||
let hasNewFiles = false;
|
||||
const newFilenames = [];
|
||||
@@ -28,21 +51,7 @@ export async function handleFiles(input) {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles && App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
App.newFilenames = newFilenames;
|
||||
}
|
||||
if (hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
hideAgentRequest();
|
||||
await _uploadNewFiles(newFilenames);
|
||||
App.newFilenames = newFilenames;
|
||||
App.processState = 'idle';
|
||||
checkAutoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
promptMissingConfig();
|
||||
input.value = '';
|
||||
return { hasNewFiles, newFilenames };
|
||||
}
|
||||
|
||||
async function _uploadNewFiles(filenames) {
|
||||
@@ -90,35 +99,18 @@ export function initDragDrop() {
|
||||
zone.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const validExts = /\.(pdf|png|jpe?g|bmp|webp)$/i;
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
let hasNewFiles = false;
|
||||
const newFilenames = [];
|
||||
const result = await _processFiles(droppedFiles);
|
||||
|
||||
for (const f of droppedFiles) {
|
||||
if (/\.json$/i.test(f.name)) {
|
||||
await parseConfigFile(f);
|
||||
continue;
|
||||
}
|
||||
if (validExts.test(f.name) && !App.allFiles.find(x => x.name === f.name)) {
|
||||
f.__source = 'local';
|
||||
App.allFiles.push(f);
|
||||
zone.classList.add('active');
|
||||
addFileMessage(f.name);
|
||||
hasNewFiles = true;
|
||||
newFilenames.push(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles && App.processState === 'done') {
|
||||
if (result.hasNewFiles && App.processState === 'done') {
|
||||
App.processState = 'idle';
|
||||
}
|
||||
|
||||
// 在 awaiting_supplement 状态下,只上传新文件并走 supplement 流程
|
||||
if (hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
if (result.hasNewFiles && App.processState === 'awaiting_supplement') {
|
||||
hideAgentRequest();
|
||||
await _uploadNewFiles(newFilenames);
|
||||
App.newFilenames = newFilenames;
|
||||
await _uploadNewFiles(result.newFilenames);
|
||||
App.newFilenames = result.newFilenames;
|
||||
App.processState = 'idle';
|
||||
checkAutoStart();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user