agent 模式补充文件功能已经实现

This commit is contained in:
wandering
2026-06-15 10:39:01 +08:00
parent 62360baf5c
commit e9e0252638
33 changed files with 1664 additions and 1651 deletions
-2
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-13
View File
@@ -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`:普通发票信息提取的系统提示词
-3
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 图片字符串列表(每页一个)。
+2 -1
View File
@@ -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
View File
@@ -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)
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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"})
+5 -4
View File
@@ -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:
+87
View File
@@ -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
View File
@@ -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');
+209 -1
View File
@@ -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);
}
-4
View File
@@ -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;
+9 -89
View File
@@ -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();
+144
View File
@@ -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');
}
}
}
+2 -4
View File
@@ -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
View File
@@ -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;