diff --git a/.env.example b/.env.example index 200986f..9c72726 100644 --- a/.env.example +++ b/.env.example @@ -52,13 +52,15 @@ PG_PASSWORD= PG_SSLMODE=prefer CHECKPOINT_BACKEND=postgres -# Redis(建议 db1,前缀 inquiry_robot:;禁止 ytd:prod:) +# Redis(建议逻辑库号 /1,键前缀 inquiry_robot:;禁止 ytd:prod:) +# 说明:Redis 没有「库名 inquiry_robot」,只有数字 db;可读名靠 REDIS_KEY_PREFIX YTD_REDIS_HOST=10.206.0.14 YTD_REDIS_PORT=6379 YTD_REDIS_USERNAME=ytd_app REDIS_URL=redis://ytd_app:<密码>@10.206.0.14:6379/1 REDIS_KEY_PREFIX=inquiry_robot: -REDIS_REQUIRED=true +REDIS_BACKEND=redis +MESSAGE_STORE_BACKEND=auto # 对象存储桶 inquiry_robot(禁止 ytd-inquiry) OBJECT_STORAGE_ENDPOINT_URL=http://127.0.0.1:9000 diff --git a/.gitignore b/.gitignore index 9c874f9..1056abd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .env .env.* !.env.example +!inquiry-backend/.env.production +!inquiry-backend/.env.development *.pem *.key secrets/ @@ -9,6 +11,9 @@ secrets/ **/application-secret.yml # 含生产明文密钥,禁止进仓(见 prompt/00-INDEX.md) prompt/03-配置与密钥清单.md +# 现网拉取只读区(含 .env / secure 明文) +_ref/ +_ref/** # Python .venv/ diff --git a/aibot-bridge/README.md b/aibot-bridge/README.md index e7fdb25..1dcee84 100644 --- a/aibot-bridge/README.md +++ b/aibot-bridge/README.md @@ -7,4 +7,13 @@ node server.cjs curl http://127.0.0.1:8913/health ``` -并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。 +转发联调(智能体 HTTP 需已起): + +```text +curl -X POST http://127.0.0.1:8913/forward -H "Content-Type: application/json" -d "{\"sender_id\":\"userid\",\"message_id\":\"m1\",\"content\":\"hi\",\"chat_type\":\"group\"}" +``` + +环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`。 + +并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。 +`long_connection.cjs`:启用时只跑帧循环/心跳壳(默认不连 Bot SDK);正式 SDK 只替换传输段。 diff --git a/aibot-bridge/long_connection.cjs b/aibot-bridge/long_connection.cjs new file mode 100644 index 0000000..a8f4895 --- /dev/null +++ b/aibot-bridge/long_connection.cjs @@ -0,0 +1,67 @@ +/** + * AIBOT 长连接传输壳(无业务)。 + * + * 本文件职责: + * - 当 WECOM_AIBOT_ENABLED=true 时启动「帧循环」占位(默认只心跳日志,不连真 Bot SDK) + * - 收到已规范化的入站帧时转发到同进程 forwardToAgent(由 server.cjs 注入) + * + * 禁止:写工单/六态/Graph;并存期勿与现网 8813 双连同一 Bot。 + * 正式 SDK 接入:替换 startLongConnectionLoop 内的 connect 段,保持只做传输。 + */ + +"use strict"; + +/** + * @param {object} opts + * @param {boolean} opts.enabled + * @param {(payloadText: string) => Promise} opts.forwardToAgent + * @param {() => boolean} [opts.shouldStop] + */ +function startLongConnectionLoop(opts) { + const enabled = !!opts.enabled; + const forwardToAgent = opts.forwardToAgent; + const shouldStop = opts.shouldStop || (() => false); + + if (!enabled) { + // eslint-disable-next-line no-console + console.log("[aibot-long-connection] 未启用(WECOM_AIBOT_ENABLED=false),跳过帧循环壳"); + return { stop: () => {} }; + } + + // eslint-disable-next-line no-console + console.warn( + "[aibot-long-connection] 帧循环壳已启动:当前不连企微 Bot SDK,仅心跳;" + + "请确认现网桥已停,避免双连" + ); + + let timer = null; + const tick = () => { + if (shouldStop()) { + return; + } + // 心跳:证明传输壳在跑;无帧可转发 + // eslint-disable-next-line no-console + console.log("[aibot-long-connection] heartbeat shell (no SDK frame)"); + }; + timer = setInterval(tick, 60000); + tick(); + + return { + stop: () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + /** + * 测试注入:模拟 SDK 收到一帧已解析 JSON,只做转发。 + * @param {object|string} frame + */ + injectFrameForTest: async (frame) => { + const text = typeof frame === "string" ? frame : JSON.stringify(frame); + return forwardToAgent(text); + }, + }; +} + +module.exports = { startLongConnectionLoop }; diff --git a/aibot-bridge/server.cjs b/aibot-bridge/server.cjs index 6794b58..4b13809 100644 --- a/aibot-bridge/server.cjs +++ b/aibot-bridge/server.cjs @@ -1,46 +1,142 @@ /** - * AIBOT 长连接桥(骨架) + * AIBOT 长连接桥(骨架 + HTTP 转发) * - * 本文件职责:仅做本机回环 HTTP 健康检查与转发壳;不拥有业务状态。 - * 端口:环境变量 AIBOT_BRIDGE_PORT(测试默认 8913),绑定 AIBOT_BRIDGE_BIND(默认 127.0.0.1)。 - * 禁止:并存期连接现网 Bot 长连接(现网 8813 已占用);禁止在本进程写工单/六态/Graph。 - * 启动:在 aibot-bridge 目录执行 node server.cjs + * 本文件职责: + * - /health 存活 + * - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound) + * - 默认不连企微 Bot 长连接(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时仅打警告 + * + * 禁止:在本进程写工单/六态/Graph。 */ "use strict"; const http = require("http"); +const { URL } = require("url"); +const { startLongConnectionLoop } = require("./long_connection.cjs"); const port = Number(process.env.AIBOT_BRIDGE_PORT || 8913); const bind = process.env.AIBOT_BRIDGE_BIND || "127.0.0.1"; const enabled = String(process.env.WECOM_AIBOT_ENABLED || "false").toLowerCase() === "true"; +const ingressUrl = + process.env.WECOM_AIBOT_INGRESS_URL || + "http://127.0.0.1:8910/internal/aibot/inbound"; +const bridgeToken = process.env.WECOM_AIBOT_BRIDGE_TOKEN || ""; -const server = http.createServer((req, res) => { - // 只处理短请求;真实 AIBOT 帧转发后续迭代。请求线程不做业务。 - if (req.url === "/health" || req.url === "/health/") { - const body = JSON.stringify({ +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJson(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); + res.end(body); +} + +function forwardToAgent(payloadText) { + return new Promise((resolve, reject) => { + let target; + try { + target = new URL(ingressUrl); + } catch (e) { + reject(e); + return; + } + const data = Buffer.from(payloadText, "utf8"); + const req = http.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || 80, + path: target.pathname + (target.search || ""), + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": data.length, + "X-Aibot-Bridge-Token": bridgeToken, + }, + timeout: 5000, + }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + } + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("ingress_timeout")); + }); + req.write(data); + req.end(); + }); +} + +const server = http.createServer(async (req, res) => { + const url = (req.url || "").split("?")[0]; + + if (req.method === "GET" && (url === "/health" || url === "/health/")) { + sendJson(res, 200, { status: "UP", service: "aibot-bridge", bind, port, wecomAibotEnabled: enabled, + ingressUrl, + longConnectionShell: true, note: enabled - ? "enabled flag on — 仍须确认未与现网双连" - : "scaffold: Bot 长连接默认关闭(并存期禁止再连)", + ? "enabled:长连接帧循环壳已起(默认无 Bot SDK);勿与现网双连" + : "Bot 长连接默认关闭;可用 POST /forward 测转发", }); - res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); - res.end(body); return; } - res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ error: "not_found", path: req.url })); + // 测试/后续 Bot 回调:把 JSON 原样转到智能体(不做业务) + if (req.method === "POST" && (url === "/forward" || url === "/forward/")) { + try { + const raw = await readBody(req); + if (!raw) { + sendJson(res, 400, { error: "empty_body" }); + return; + } + // 轻量校验 JSON + JSON.parse(raw); + const out = await forwardToAgent(raw); + sendJson(res, 200, { + status: "forwarded", + ingressStatus: out.status, + ingressBody: (() => { + try { + return JSON.parse(out.body); + } catch (_) { + return out.body; + } + })(), + }); + } catch (e) { + sendJson(res, 502, { error: "forward_failed", message: String(e && e.message ? e.message : e) }); + } + return; + } + + sendJson(res, 404, { error: "not_found", path: req.url }); }); server.listen(port, bind, () => { // eslint-disable-next-line no-console console.log( - `[aibot-bridge] listening http://${bind}:${port}/health wecomAibotEnabled=${enabled}` + `[aibot-bridge] http://${bind}:${port}/health forward→${ingressUrl} enabled=${enabled}` ); if (enabled) { // eslint-disable-next-line no-console @@ -48,4 +144,9 @@ server.listen(port, bind, () => { "[aibot-bridge] WECOM_AIBOT_ENABLED=true:请确认已停现网桥且不会双连同一 Bot" ); } + // 传输壳:enabled 时跑心跳循环;未接 SDK 前不会产生入站帧 + startLongConnectionLoop({ + enabled, + forwardToAgent, + }); }); diff --git a/contracts/README.md b/contracts/README.md index bd6a4e4..660e165 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,20 +1,15 @@ # 字段合同(contracts) -本目录存放与现网等价的字段 / 字典投影 JSON。智能体写字段校验、主账写 TMS 字典投影前,**必须**以本目录真文件为准。 +本目录存放字段 / 字典投影 JSON。智能体写字段校验、主账写 TMS 字典投影前,**必须**以本目录**正式**文件为准。 -## 需要从现网拷贝的文件 +## 当前状态 -| 文件名 | 用途 | +| 文件名 | 状态 | |---|---| -| `inquiry-required-fields-v1.json` | 询价必填与条件激活合同 | -| `tms-dictionary-projection-v2.json` | TMS 字典 EXACT_ONLY 投影合同 | +| `inquiry-required-fields-v1.json` | 正式(已从现网对照源拉入) | +| `tms-dictionary-projection-v2.json` | 正式(已从现网对照源拉入) | +| `*.PLACEHOLDER.json` | 历史占位,加载器优先正式文件 | -骨架阶段仅放占位文件(见同名 `*.PLACEHOLDER.json`)。拿到现网拷贝后: +备份另见 `_ref/prod-pull/contracts/`(勿提交密钥目录)。 -1. 覆盖为正式文件名(去掉 `.PLACEHOLDER`)。 -2. 再实现字段校验 / 字典投影;没读真 JSON 不准写校验逻辑。 - -## 禁止 - -- 不要把合同只记在提示词里而不更新本目录。 -- 不要在代码里硬编码另一套字段名。 +代码加载:`agent.schema.load_contract` / `require_formal_contract`。 diff --git a/contracts/inquiry-required-fields-v1.PLACEHOLDER.json b/contracts/inquiry-required-fields-v1.PLACEHOLDER.json index 201053e..72f440d 100644 --- a/contracts/inquiry-required-fields-v1.PLACEHOLDER.json +++ b/contracts/inquiry-required-fields-v1.PLACEHOLDER.json @@ -1,5 +1,5 @@ { - "_comment": "占位:请用现网 inquiry-required-fields-v1.json 覆盖为同名正式文件后再写字段校验。骨架阶段禁止依赖本文件做业务判断。", + "_comment": "占位:正式合同由产品/仓库内审交付到同名 inquiry-required-fields-v1.json。禁止从现网服务器或现网发布目录拷贝。骨架阶段禁止依赖本文件做业务判断。", "schemaVersion": "v1-placeholder", "fields": [] } diff --git a/contracts/inquiry-required-fields-v1.json b/contracts/inquiry-required-fields-v1.json new file mode 100644 index 0000000..ff569bd --- /dev/null +++ b/contracts/inquiry-required-fields-v1.json @@ -0,0 +1,102 @@ +{ + "schema_version": 1, + "contract_id": "INQUIRY_REQUIRED_FIELDS_V1", + "status": "APPROVED", + "approved_by": "user", + "approved_at": "2026-08-30T02:30:00+08:00", + "requirement_id": "REQ-20260830-030", + "last_amended_requirement_id": "REQ-20260831-009 revision 1", + "stage_semantics": { + "PRICE_REQUIRED": "首次查价必须满足的字段;只按已确认的业务线和二级类型激活。", + "COLLAB_REQUIRED": "首次查价字段保持有效后,进入协同阶段追加的必填字段。" + }, + "value_validity": { + "scope": "ONLY_CURRENTLY_ACTIVATED_REQUIRED_FIELDS", + "completion_rule": "TRIMMED_NON_EMPTY_VALUE_IS_PRESENT", + "content_validation": "DISABLED_LOCAL_VALUE_FORMAT_AND_COMPATIBILITY_VALIDATION_EXCEPT_DECLARED_MACHINE_TYPES", + "accepted_user_values": "当前作用域字段收到任意非空值即视为已采集;本地不得按单位、车型、布尔文案、别名、格式或封闭枚举拒绝。SEA箱型箱量保持一个原始业务字段,结构化containerItems只由DeepSeek B投影;投影不可用时由TMS查询边返回1003。", + "inactive_field_display_not_a_fact": "未激活字段摘要中的-只表示不适用,不作为字段值进入完整性校验。" + }, + "model_scopes": { + "PRICING": "DeepSeek B只接收本合同pricing_required中当前激活字段、必要路由身份和optional_projection_fields中当前业务线允许的可选事实;缺项只按pricing_required计算。", + "COLLABORATION": "DeepSeek B接收同一票不可变销售原话、已确认运输模式和陆运二级类型,只返回当前激活协同字段;结果保存到独立快照。点击协同时必须重新执行本范围。" + }, + "pricing_required": { + "SEA": { + "always": ["起运港", "目的港", "品名", "货量", "整柜或拼柜", "箱型箱量"], + "display_names": {"货量": "货物数量"} + }, + "AIR": { + "always": ["起运港", "目的港", "品名", "件数", "毛重", "体积", "包装方式"], + "display_names": {"起运港": "起运地", "目的港": "目的地", "毛重": "重量(KG)", "体积": "体积(CBM)", "包装方式": "包装类型"} + }, + "LAND": { + "国内运输拼车": { + "always": ["起运港", "目的港", "品名", "体积", "毛重"], + "display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "体积": "体积(CBM)", "毛重": "重量(KG)"} + }, + "国内运输整车": { + "always": ["起运港", "目的港", "品名", "车型数量"], + "display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "车型数量": "车型/数量"} + }, + "跨境集拼": { + "always": ["起运港", "目的港", "品名", "体积", "毛重"], + "display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "体积": "体积(CBM)", "毛重": "重量(KG)"} + }, + "跨境整车": { + "always": ["起运港", "目的港", "品名"], + "display_names": {"起运港": "起运地", "目的港": "目的地", "品名": "货物品名"} + }, + "中港": { + "always": ["起运港", "目的港", "品名", "车型数量"], + "display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "车型数量": "车型/数量"} + } + } + }, + "optional_projection_fields": { + "LAND": ["件数", "毛重", "体积", "时效要求", "cargo_items"], + "semantics": "这些字段不是首次查价必填项;只有销售原话明确出现且DeepSeek B给出逐项证据时才保真保存。代码不得从原始自然语言猜测,只可对模型结构化cargo_items做单位与算术投影。" + }, + "collaboration_required": { + "SEA": ["贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"], + "AIR": ["是否含电", "是否含磁"], + "LAND": { + "国内运输整车": ["时效要求", "客户名称", "货值", "包装方式", "是否危险品"], + "国内运输拼车": ["时效要求", "客户名称", "包装方式", "货值", "是否危险品"], + "跨境整车": ["时效要求", "客户名称", "HS编码", "车型数量", "贸易条款", "货值", "包装方式", "是否危险品"], + "跨境集拼": ["时效要求", "客户名称", "HS编码", "贸易条款", "包装方式", "是否危险品"], + "中港": {"same_as": "跨境整车"} + } + }, + "collaboration_value_contracts": { + "enforcement": "SUGGESTION_ONLY_USER_TYPED_NON_EMPTY_VALUE_IS_ACCEPTED", + "LAND": { + "时效要求": { + "国内运输整车": ["普件(常用线路/常规车型)", "普件(非常用线路/临时线路/特种车型)", "急件"], + "国内运输拼车": ["普件(常用线路/常规车型)", "普件(非常用线路/临时线路/特种车型)", "急件"], + "跨境整车": ["普件(中港、东南亚常用线路/常规车型)", "普件(中亚、中欧、东南亚涉及国内+国外段)", "普件(中亚、中欧、东南亚涉及境外段清派等特殊要求)", "急件"], + "跨境集拼": ["普件(中港、东南亚常用线路/常规车型)", "普件(中亚、中欧、东南亚涉及国内+国外段)", "普件(中亚、中欧、东南亚涉及境外段清派等特殊要求)", "急件"], + "中港": {"same_as": "跨境整车"} + } + } + }, + "legacy_read_aliases": { + "时效要求": ["服务档位", "货物类型", "服务类型", "普件/急件"] + }, + "semantic_ownership": { + "owner": "DeepSeek B", + "must_output": ["raw_value", "normalized_value", "evidence", "segment_binding", "cargo_binding"], + "forbidden_local_capabilities": ["semantic_regex", "alias_inference", "content_normalization", "semantic_fallback", "value_format_rejection", "closed_enum_value_rejection"] + }, + "machine_types": {}, + "deterministic_invariants": [ + "SEA货物数量与AIR件数是不同字段;自然语言数量落入哪个字段只能由DeepSeek B依据已解析业务线决定。", + "SEA箱型箱量是一个首次查价必填字段,整柜/FCL和拼柜/LCL均激活;任意非空原值完成采集。", + "SEA箱型箱量原值到containerItems.type/quantity的结构化投影只由DeepSeek B输出,本地不得拆分、去单位或限制格式。", + "DeepSeek B不能形成可用containerItems时不得重新补问箱型或箱量,由item投影或TMS查询边返回业务1003。", + "AIR包装类型使用内部字段包装方式承载,但只在AIR首次查价作用域激活。", + "尺寸原文不得在本地代码中用正则、别名或关键词解析为体积(CBM);DeepSeek B已输出带证据的结构化cargo_items后,代码可按单位做确定性乘法和加总并投影总体积。", + "协同字段只能在对应基础查价字段满足后追加,不得反向扩大首次查价必填。", + "代码只校验字段键作用域、JSON结构、非空、条件激活、权限、版本与幂等;字段内容格式、规范性和是否适合报价由模型与下游报价服务负责。" + ] +} diff --git a/contracts/tms-dictionary-projection-v2.PLACEHOLDER.json b/contracts/tms-dictionary-projection-v2.PLACEHOLDER.json index 276eb43..a302192 100644 --- a/contracts/tms-dictionary-projection-v2.PLACEHOLDER.json +++ b/contracts/tms-dictionary-projection-v2.PLACEHOLDER.json @@ -1,5 +1,5 @@ { - "_comment": "占位:请用现网 tms-dictionary-projection-v2.json 覆盖为同名正式文件后再写字典投影。骨架阶段禁止依赖本文件做业务判断。", + "_comment": "占位:正式合同由产品/仓库内审交付到同名 tms-dictionary-projection-v2.json。禁止从现网服务器或现网发布目录拷贝。骨架阶段禁止依赖本文件做业务判断。", "schemaVersion": "v2-placeholder", "projections": [] } diff --git a/contracts/tms-dictionary-projection-v2.json b/contracts/tms-dictionary-projection-v2.json new file mode 100644 index 0000000..1a05b65 --- /dev/null +++ b/contracts/tms-dictionary-projection-v2.json @@ -0,0 +1,163 @@ +{ + "schema_version": 2, + "contract_version": "TMS-DICTIONARY-PROJECTION-V2", + "supersedes": ".project-control/architecture/tms-dictionary-projection-v1.json", + "required_fields_contract_ref": ".project-control/architecture/inquiry-required-fields-v1.json", + "business_semantics_owner": "DEEPSEEK_B", + "protocol_projection_owner": "JAVA_TMS_ADAPTER", + "last_amended_requirement_id": "REQ-20260831-009 revision 1", + "matching_policy": { + "mode": "EXACT_ONLY", + "allowed_keys": ["source_code", "source_name"], + "allow_regex": false, + "allow_fuzzy": false, + "allow_alias_inference": false, + "allow_suffix_trimming": false, + "allow_last_write_wins": false, + "multiple_distinct_values": "AMBIGUOUS", + "zero_values": "UNMAPPED", + "air_package_fallback": { + "target_field": "packageType", + "value": 1, + "meaning": "全部", + "preserve_raw_source": true + } + }, + "catalog_statuses": [ + "PROJECTABLE", + "AMBIGUOUS_NAME_GROUP", + "SOURCE_VALUE_MISSING", + "REFERENCE_ONLY" + ], + "resolution_statuses": [ + "RESOLVED", + "UNMAPPED", + "AMBIGUOUS", + "SOURCE_VALUE_MISSING", + "CARDINALITY_UNSUPPORTED", + "NOT_APPLICABLE" + ], + "dictionary_domains": [ + "air_airport", + "air_packaging_type", + "land_address", + "land_vehicle", + "sea_container", + "land_transport_type", + "currency_reference" + ], + "projection_rules": [ + { + "transport_mode": "AIR", + "transport_type": null, + "source_field": "origin", + "target_field": "originCode", + "dictionary_domain": "air_airport", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "AIR", + "transport_type": null, + "source_field": "destination", + "target_field": "destinationCode", + "dictionary_domain": "air_airport", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "AIR", + "transport_type": null, + "source_field": "packagingType", + "target_field": "packageType", + "dictionary_domain": "air_packaging_type", + "output_column": "projection_value", + "default_source_value": "全部" + }, + { + "transport_mode": "LAND", + "transport_type": null, + "source_field": "origin", + "target_field": "originCode", + "dictionary_domain": "land_address", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "LAND", + "transport_type": null, + "source_field": "destination", + "target_field": "destinationCode", + "dictionary_domain": "land_address", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "LAND", + "transport_type": "DOMESTIC_FTL|HK", + "source_field": "vehicleItems", + "target_field": "vehicleType", + "dictionary_domain": "land_vehicle", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "SEA", + "transport_type": null, + "source_field": "containerItems", + "target_field": "containerType", + "dictionary_domain": "sea_container", + "output_column": "projection_value", + "default_source_value": null + }, + { + "transport_mode": "LAND", + "transport_type": null, + "source_field": "transportType", + "target_field": "landTransportType", + "dictionary_domain": "land_transport_type", + "output_column": "projection_value", + "default_source_value": null + } + ], + "direct_projection_rules": [ + { + "transport_mode": "SEA", + "transport_type": null, + "source_field": "containerItems", + "source_item_field": "quantity", + "target_field": "containerQty", + "value_type": "positive_integer", + "dictionary_domain": null, + "unit_in_value": false + } + ], + "non_blocking_projection_failures": [ + { + "transport_mode": "LAND", + "dictionary_domain": "land_address", + "target_fields": ["originCode", "destinationCode"], + "resolution_status": "UNMAPPED", + "required_source_names": ["origin", "destination"], + "behavior": "保留投影审计;名称非空时以名称且代码空值继续真实TMS查询。AMBIGUOUS、SOURCE_VALUE_MISSING及其他业务线仍失败关闭。" + } + ], + "direct_passthrough_fields": [ + { + "transport_mode": "LAND", + "source_field": "时效要求", + "target_field": "routeCategory", + "behavior": "逐字透传用户值,不做字典或本地语义映射。" + } + ], + "unsupported_projection_fields": [ + { + "field": "seaPortCode", + "reason": "SOURCE_WORKBOOK_HAS_NO_SEA_PORT_DICTIONARY" + }, + { + "field": "currency", + "reason": "WORKBOOK_NUMERIC_VALUE_IS_REFERENCE_ONLY_NOT_ISO4217_PROJECTION" + } + ] +} diff --git a/deploy/Deploy-YtdAdminLatest.ps1 b/deploy/Deploy-YtdAdminLatest.ps1 new file mode 100644 index 0000000..4b524b0 --- /dev/null +++ b/deploy/Deploy-YtdAdminLatest.ps1 @@ -0,0 +1,58 @@ +# 本地一键:同步源码 → 构建最新 dist → 上传 → 切后台到 8180 +# 用法:在仓库根目录 .\deploy\Deploy-YtdAdminLatest.ps1 +# 要求:SSH 密钥 ~/.ssh/id_ed25519_ytd;服务器 8180/8910/8080 已在听。 +# 副作用:更新 E:\wwwroot\admin\current;改 Nginx /jeecgboot -> 8180;不碰 C:\yutongda。 + +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$SrcBackend = Join-Path $RepoRoot 'inquiry-backend' +$BuildRoot = 'C:\ytd\inquiry-backend' +$Key = Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd' +$Remote = 'ytd_extdev_0903@129.211.181.80' +$Incoming = 'C:/Users/ytd_extdev_0903/incoming' + +if (-not (Test-Path $Key)) { throw "missing SSH key $Key" } +if (-not (Test-Path $SrcBackend)) { throw "missing $SrcBackend" } + +Write-Host '=== 1) sync source to english build path ===' +New-Item -ItemType Directory -Force -Path (Split-Path $BuildRoot) | Out-Null +# 排除 node_modules / dist / .git,再单独保证依赖 +robocopy $SrcBackend $BuildRoot /MIR /XD node_modules dist .git .vite .husky /XF *.log /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +# robocopy exit 0-7 ok +if ($LASTEXITCODE -ge 8) { throw "robocopy failed code=$LASTEXITCODE" } +Copy-Item (Join-Path $SrcBackend '.env.production') (Join-Path $BuildRoot '.env.production') -Force + +if (-not (Test-Path (Join-Path $BuildRoot 'node_modules'))) { + Write-Host '=== pnpm install ===' + Push-Location $BuildRoot + try { pnpm install --frozen-lockfile } catch { pnpm install } + Pop-Location +} + +Write-Host '=== 2) build production dist ===' +Push-Location $BuildRoot +if (Test-Path 'dist') { Remove-Item 'dist' -Recurse -Force } +pnpm run build +if ($LASTEXITCODE -ne 0) { Pop-Location; throw "build failed $LASTEXITCODE" } +if (-not (Test-Path 'dist\index.html')) { Pop-Location; throw 'dist/index.html missing' } +Pop-Location + +$zip = Join-Path $env:TEMP 'admin-dist.zip' +if (Test-Path $zip) { Remove-Item $zip -Force } +Compress-Archive -Path (Join-Path $BuildRoot 'dist\*') -DestinationPath $zip -Force +Write-Host ("zip bytes=" + (Get-Item $zip).Length) + +Write-Host '=== 3) upload ===' +scp -o BatchMode=yes -i $Key ` + $zip ` + (Join-Path $RepoRoot 'deploy\ai.conf.cutover-8180') ` + (Join-Path $RepoRoot 'deploy\_remote_cutover_admin_8180.ps1') ` + "${Remote}:${Incoming}/" +if ($LASTEXITCODE -ne 0) { throw "scp failed $LASTEXITCODE" } + +Write-Host '=== 4) remote cutover ===' +ssh -o BatchMode=yes -i $Key $Remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_cutover_admin_8180.ps1" +if ($LASTEXITCODE -ne 0) { throw "remote cutover failed $LASTEXITCODE" } + +Write-Host 'DONE: admin latest published; /jeecgboot -> 8180' +Write-Host '请强刷 https://ai.ytd-scm.com/ (Ctrl+F5),用 Jeecg 账号登录' diff --git a/deploy/README.md b/deploy/README.md index 2305e9d..95edbeb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,10 +1,20 @@ -# 部署占位(仅测试机) +# 部署占位(仅测试机 / 切流后 wwwroot) -新系统与现网同机并存时: +新系统运行根:`E:\wwwroot`(`admin\current` / `agent\current`)。 -- 运行根只允许 `C:\ytd-test\`(或本机开发目录)。 -- **禁止**改 `C:\yutongda\ops\active-release*.json`,禁止占现网 8080 / 8810 / 8813。 -- **禁止**改 Nginx `ai.conf` 回调 / H5 反代(切流前公网仍进现网)。 -- 并存期 **禁止**让测试 AIBOT 桥再连现网 Bot 长连接。 +## 每次发最新后台(正式 Jeecg Vue) -脚本见 `Start-YtdTestScaffold.ps1`:仅用于本机/测试机起骨架进程做健康检查,不是正式发布器。 +在仓库根执行: + +```powershell +.\deploy\Deploy-YtdAdminLatest.ps1 +``` + +会:同步 `inquiry-backend` → 英文路径构建 → 上传 `admin-dist.zip` → 切 `admin\current` → Nginx `/jeecgboot` 指到 **8180**。 +禁止改 `C:\yutongda`。现网旧路径 `/jeecg-boot` 仍可指 8080 作兼容。 + +## 其他 + +- 智能体安全发布:`_remote_safe_deploy_agent.ps1`(不改 Nginx) +- 主账 WMI 拉起:`_remote_wmi_start_ledger.ps1` +- 并存期勿双连 AIBOT(8913) diff --git a/deploy/Watch-Agent8910.ps1 b/deploy/Watch-Agent8910.ps1 new file mode 100644 index 0000000..45a9896 --- /dev/null +++ b/deploy/Watch-Agent8910.ps1 @@ -0,0 +1,103 @@ +# 只读:拉服务器新系统 8910 智能体日志中的企微相关行与错误行。 +# 用法(仓库根或任意目录): +# .\deploy\Watch-Agent8910.ps1 # 持续监听(Ctrl+C 结束) +# .\deploy\Watch-Agent8910.ps1 -Once # 只看最近一段 +# .\deploy\Watch-Agent8910.ps1 -All # 不过滤关键字,整行刷出 +# +# 说明: +# - 盯的是 E:\wwwroot\logs\agent-http.*.log(新系统 8910),不碰现网 8810/8811。 +# - 切流前公网 /wecom/callback 仍进现网;本脚本主要看到 inject、出站、本机调 8910 的错误。 +# - 依赖本机 SSH 密钥 ~/.ssh/id_ed25519_ytd。 + +[CmdletBinding()] +param( + [switch]$Once, + [switch]$All, + [int]$Tail = 80 +) + +$ErrorActionPreference = 'Stop' +$Key = Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd' +$Remote = 'ytd_extdev_0903@129.211.181.80' +$LogOut = 'E:\wwwroot\logs\agent-http.out.log' +$LogErr = 'E:\wwwroot\logs\agent-http.err.log' + +# 企微回调 / 出站 / 注入 / 常见失败码;-All 时不使用 +$Pattern = 'wecom|callback|inject|outbox|inbox|errcode|errmsg|60020|ERROR|WARNING|Traceback|Exception|health|sender|touser|crypto' + +if (-not (Test-Path $Key)) { + throw "缺少 SSH 密钥: $Key" +} + +Write-Host "目标: ${Remote}" +Write-Host "日志: $LogOut" +Write-Host " $LogErr" +if ($All) { + Write-Host '过滤: 关闭(全部输出)' +} else { + Write-Host "过滤: $Pattern" +} +Write-Host '---' + +# 远程脚本:先打 health,再 Tail;-Wait 仅在非 Once 时启用 +$waitLiteral = if ($Once) { '$false' } else { '$true' } +$allLiteral = if ($All) { '$true' } else { '$false' } + +$remote = @" +`$ErrorActionPreference = 'Continue' +`$out = '$LogOut' +`$err = '$LogErr' +`$tail = $Tail +`$wait = $waitLiteral +`$all = $allLiteral +`$pat = '$Pattern' + +Write-Output '=== remote health 8910 ===' +try { + `$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5 + Write-Output `$h.Content +} catch { + Write-Output ('health_fail: ' + `$_.Exception.Message) +} +Write-Output '=== log tail ===' + +`$files = @() +if (Test-Path `$out) { `$files += `$out } else { Write-Output ('MISSING ' + `$out) } +if (Test-Path `$err) { `$files += `$err } else { Write-Output ('MISSING ' + `$err) } +if (`$files.Count -eq 0) { throw 'no agent-http log files' } + +function Show-Line([string]`$line) { + if (`$all) { + Write-Output `$line + return + } + if (`$line -match `$pat) { + Write-Output `$line + } +} + +if (-not `$wait) { + foreach (`$f in `$files) { + Write-Output ('--- ' + `$f + ' ---') + Get-Content -Path `$f -Tail `$tail -ErrorAction SilentlyContinue | ForEach-Object { Show-Line `$_ } + } + exit 0 +} + +Write-Output 'following... (Ctrl+C to stop)' +Get-Content -Path `$files -Tail `$tail -Wait -ErrorAction SilentlyContinue | ForEach-Object { Show-Line `$_ } +"@ + +$remoteFile = Join-Path $env:TEMP 'ytd-watch-agent8910-remote.ps1' +Set-Content -Path $remoteFile -Value $remote -Encoding UTF8 + +$incoming = 'C:/Users/ytd_extdev_0903/incoming/_watch_agent8910.ps1' +& scp -o BatchMode=yes -o ConnectTimeout=12 -i $Key $remoteFile "${Remote}:${incoming}" +if ($LASTEXITCODE -ne 0) { throw "scp failed: $LASTEXITCODE" } + +& ssh -o BatchMode=yes -o ConnectTimeout=12 -i $Key $Remote ` + "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_watch_agent8910.ps1" +if ($LASTEXITCODE -ne 0 -and -not $Once) { + # Ctrl+C 常非 0,不当作部署失败 + exit $LASTEXITCODE +} diff --git a/deploy/_remote_accept_check.ps1 b/deploy/_remote_accept_check.ps1 new file mode 100644 index 0000000..6c30396 --- /dev/null +++ b/deploy/_remote_accept_check.ps1 @@ -0,0 +1,44 @@ +# 发布后验收:端口、健康、现网未误停 +$ErrorActionPreference = 'Continue' +Write-Output '=== ports ===' +foreach ($x in 8080, 8810, 8813, 8180, 8910, 8913) { + $c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1 + if ($c) { Write-Output "$x LISTEN pid=$($c.OwningProcess)" } else { Write-Output "$x down" } +} + +Write-Output '=== health ===' +foreach ($u in @( + 'http://127.0.0.1:8910/health', + 'http://127.0.0.1:8180/jeecgboot/', + 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/deploycheck', + 'http://127.0.0.1:8080/jeecg-boot/' +)) { + try { + $r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 8 + Write-Output ("OK " + $r.StatusCode + " " + $u) + } catch { + $code = $null + try { $code = $_.Exception.Response.StatusCode.value__ } catch {} + Write-Output ("FAIL " + $u + " code=" + $code) + } +} + +Write-Output '=== worker ===' +$found = $false +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and $_.CommandLine -like '*worker_app*' -and $_.CommandLine -like '*wwwroot*agent*') { + Write-Output ("worker pid=" + $_.ProcessId) + $found = $true + } +} +if (-not $found) { Write-Output 'worker NOT found' } + +Write-Output '=== pointer ===' +Get-Content 'E:\wwwroot\ops\active-release.json' -Raw + +Write-Output '=== yutongda pointer untouched ===' +if (Test-Path 'C:\yutongda\ops\active-release.json') { + Write-Output 'C:\yutongda\ops\active-release.json still exists (not deleted)' +} + +Write-Output 'ACCEPT_DONE' diff --git a/deploy/_remote_cfg_check.ps1 b/deploy/_remote_cfg_check.ps1 new file mode 100644 index 0000000..e72c18d --- /dev/null +++ b/deploy/_remote_cfg_check.ps1 @@ -0,0 +1,13 @@ +# 检查 config overlay 是否含 redis username,以及最近失败原因 +$ErrorActionPreference = 'Continue' +$cfg = 'E:\wwwroot\inquiry-new\config\application-inquiry.yml' +Write-Output '=== config exists ===' +Test-Path $cfg +if (Test-Path $cfg) { + Get-Content $cfg | Select-Object -First 25 +} +Write-Output '=== log markers ===' +Select-String -Path 'E:\wwwroot\inquiry-new\logs\stdout.log' -Pattern 'WRONGPASS|redisContainer|Started Jeecg|Application run failed' | + Select-Object -Last 12 | + ForEach-Object { $_.Line.Substring(0, [Math]::Min(220, $_.Line.Length)) } +Write-Output 'CFG_CHECK_DONE' diff --git a/deploy/_remote_check_hint.ps1 b/deploy/_remote_check_hint.ps1 new file mode 100644 index 0000000..7a04269 --- /dev/null +++ b/deploy/_remote_check_hint.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Continue' +$hits = Select-String -Path 'E:\wwwroot\admin\current\assets\*.js' -Pattern '数据进入主账' -SimpleMatch -ErrorAction SilentlyContinue +if ($hits) { + Write-Output 'HINT_STILL_PRESENT' + $hits | Select-Object -First 3 | ForEach-Object { $_.Filename } +} else { + Write-Output 'HINT_GONE' +} +Write-Output 'CHECK_DONE' diff --git a/deploy/_remote_cleanup_after_deploy.ps1 b/deploy/_remote_cleanup_after_deploy.ps1 new file mode 100644 index 0000000..a4f0682 --- /dev/null +++ b/deploy/_remote_cleanup_after_deploy.ps1 @@ -0,0 +1,77 @@ +# After redeploy: remove all releases except current junction targets +$ErrorActionPreference = 'Continue' +$Root = 'E:\wwwroot' + +function Resolve-Link([string]$p) { + if (-not (Test-Path $p)) { return $null } + $i = Get-Item $p -Force + if ($i.Target -and $i.Target.Count -gt 0) { return [string]$i.Target[0] } + return $null +} + +$keepAgent = Resolve-Link (Join-Path $Root 'agent\current') +$keepAdmin = Resolve-Link (Join-Path $Root 'admin\current') +Write-Output ('keepAgent=' + $keepAgent) +Write-Output ('keepAdmin=' + $keepAdmin) + +function Try-Remove([string]$path) { + try { + Remove-Item $path -Recurse -Force -ErrorAction Stop + Write-Output ('REMOVED ' + $path) + return $true + } catch { + Write-Output ('LOCK ' + $path + ' :: ' + $_.Exception.Message) + return $false + } +} + +# Stop python/java only if commandline clearly points at OLD release paths we will delete +$keepAgentRoot = $null +if ($keepAgent) { + # .../releases//agent -> releases/ + $keepAgentRoot = Split-Path $keepAgent -Parent +} + +Get-CimInstance Win32_Process | ForEach-Object { + $cl = $_.CommandLine + if (-not $cl) { return } + if ($cl -like '*\agent\releases\*' -and $keepAgentRoot -and $cl -notlike ('*' + $keepAgentRoot + '*')) { + if ($cl -like '*python*' -or $cl -like '*http_app*' -or $cl -like '*worker_app*') { + Write-Output ('stop stale pid=' + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + } +} +Start-Sleep -Seconds 2 + +if (Test-Path (Join-Path $Root 'agent\releases')) { + Get-ChildItem (Join-Path $Root 'agent\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object { + $rel = $_.FullName + $agentHomePath = Join-Path $rel 'agent' + if ($keepAgent -and ($keepAgent -eq $agentHomePath -or $keepAgent.StartsWith($rel + '\'))) { + Write-Output ('KEEP ' + $rel) + } else { + Try-Remove $rel | Out-Null + } + } +} + +if (Test-Path (Join-Path $Root 'admin\releases')) { + Get-ChildItem (Join-Path $Root 'admin\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object { + $rel = $_.FullName + $dist = Join-Path $rel 'dist' + if ($keepAdmin -and ($keepAdmin -eq $dist -or $keepAdmin.StartsWith($rel + '\'))) { + Write-Output ('KEEP ' + $rel) + } else { + Try-Remove $rel | Out-Null + } + } +} + +# trim env bak keep 2 newest +Get-ChildItem $Root -Filter '.env.bak*' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -Skip 2 | + ForEach-Object { Try-Remove $_.FullName | Out-Null } + +Write-Output 'CLEANUP_AFTER_OK' diff --git a/deploy/_remote_cleanup_old_releases.ps1 b/deploy/_remote_cleanup_old_releases.ps1 new file mode 100644 index 0000000..f8265c0 --- /dev/null +++ b/deploy/_remote_cleanup_old_releases.ps1 @@ -0,0 +1,119 @@ +# Cleanup old releases under E:\wwwroot; keep current targets only +$ErrorActionPreference = 'Stop' +$Root = 'E:\wwwroot' + +function Get-JunctionTarget([string]$link) { + if (-not (Test-Path $link)) { return $null } + $item = Get-Item $link -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + cmd /c ("dir /AL `"$link`"") | Out-Null + # Resolve via Get-Item Target if available + try { + if ($item.Target -and $item.Target.Count -gt 0) { return [string]$item.Target[0] } + } catch {} + } + # Fallback: read active-release.json + return $null +} + +$ptrPath = Join-Path $Root 'ops\active-release.json' +$keepAgent = $null +$keepAdmin = $null +if (Test-Path $ptrPath) { + $ptr = Get-Content $ptrPath -Raw | ConvertFrom-Json + if ($ptr.agentHome) { $keepAgent = [string]$ptr.agentHome } + if ($ptr.adminRelease) { $keepAdmin = [string]$ptr.adminRelease } +} + +$agentCurrent = Join-Path $Root 'agent\current' +$adminCurrent = Join-Path $Root 'admin\current' +$t1 = Get-JunctionTarget $agentCurrent +$t2 = Get-JunctionTarget $adminCurrent +if ($t1) { $keepAgent = $t1 } +if ($t2) { $keepAdmin = $t2 } + +# Resolve junction via fsutil / dir listing +function Resolve-Link([string]$p) { + if (-not (Test-Path $p)) { return $null } + $out = cmd /c ("fsutil reparsepoint query `"$p`"") 2>$null + # Alternative: (Get-Item).Target + $i = Get-Item $p -Force + if ($i.LinkType -eq 'Junction' -or ($i.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + if ($i.Target) { return ([string]$i.Target[0]) } + } + return $null +} +$ka = Resolve-Link $agentCurrent +$kb = Resolve-Link $adminCurrent +if ($ka) { $keepAgent = $ka } +if ($kb) { $keepAdmin = $kb } + +Write-Output ('keepAgent=' + $keepAgent) +Write-Output ('keepAdmin=' + $keepAdmin) + +$removed = @() +# agent releases: E:\wwwroot\agent\releases\\agent or +$agentRelRoot = Join-Path $Root 'agent\releases' +if (Test-Path $agentRelRoot) { + Get-ChildItem $agentRelRoot -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object { + $rel = $_.FullName + $agentHome = Join-Path $rel 'agent' + $isKeep = $false + if ($keepAgent) { + if ($keepAgent -eq $agentHome -or $keepAgent -eq $rel -or $keepAgent.StartsWith($rel + '\')) { $isKeep = $true } + } + if (-not $isKeep) { + Write-Output ('REMOVE agent ' + $rel) + Remove-Item $rel -Recurse -Force + $removed += $rel + } else { + Write-Output ('KEEP agent ' + $rel) + } + } +} + +$adminRelRoot = Join-Path $Root 'admin\releases' +if (Test-Path $adminRelRoot) { + Get-ChildItem $adminRelRoot -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object { + $rel = $_.FullName + $dist = Join-Path $rel 'dist' + $isKeep = $false + if ($keepAdmin) { + if ($keepAdmin -eq $dist -or $keepAdmin -eq $rel -or $keepAdmin.StartsWith($rel + '\')) { $isKeep = $true } + } + if (-not $isKeep) { + Write-Output ('REMOVE admin ' + $rel) + Remove-Item $rel -Recurse -Force + $removed += $rel + } else { + Write-Output ('KEEP admin ' + $rel) + } + } +} + +# old env backups older than keep recent 2 +Get-ChildItem $Root -Filter '.env.bak*' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -Skip 2 | ForEach-Object { + Write-Output ('REMOVE bak ' + $_.Name) + Remove-Item $_.FullName -Force + $removed += $_.FullName +} + +# incoming zips older +$incoming = 'C:\Users\ytd_extdev_0903\incoming' +if (Test-Path $incoming) { + Get-ChildItem $incoming -Include '*.zip','jeecg-system-start*.jar' -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | + ForEach-Object { + Write-Output ('REMOVE incoming ' + $_.Name) + Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue + } +} + +Write-Output ('removed_count=' + $removed.Count) +Write-Output 'CLEANUP_OK' + +# ports sanity: must not touch yutongda +foreach ($x in 8080,8180,8910) { + $c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1 + if ($c) { Write-Output ("port $x LISTEN") } else { Write-Output ("port $x down") } +} diff --git a/deploy/_remote_cutover_admin_8180.ps1 b/deploy/_remote_cutover_admin_8180.ps1 new file mode 100644 index 0000000..c1b5f7a --- /dev/null +++ b/deploy/_remote_cutover_admin_8180.ps1 @@ -0,0 +1,107 @@ +# Remote: publish latest admin dist + switch Nginx /jeecgboot -> 8180 +# Do not touch C:\yutongda. On nginx -t failure, restore backup. +$ErrorActionPreference = 'Stop' +$IncomingZip = 'C:\Users\ytd_extdev_0903\incoming\admin-dist.zip' +$IncomingConf = 'C:\Users\ytd_extdev_0903\incoming\ai.conf.cutover-8180' +$Root = 'E:\wwwroot' +$Nginx = 'C:\nginx-1.18.0' +$NginxConf = Join-Path $Nginx 'myconf\ai.conf' +$Stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$RelId = 'admin-' + $Stamp +$RelDist = Join-Path $Root ('admin\releases\' + $RelId + '\dist') +$Current = Join-Path $Root 'admin\current' +$PtrPath = Join-Path $Root 'ops\active-release.json' + +if (-not (Test-Path $IncomingZip)) { throw 'missing admin-dist.zip' } +if (-not (Test-Path $IncomingConf)) { throw 'missing ai.conf.cutover-8180' } + +$ledger = Get-NetTCPConnection -LocalPort 8180 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $ledger) { throw 'CRITICAL: 8180 not listening' } +$agent = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $agent) { throw 'CRITICAL: 8910 not listening' } +$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $prod) { throw 'CRITICAL: prod 8080 down' } + +Write-Output '=== backup nginx ===' +$bak = $NginxConf + '.bak-before-admin-8180-' + $Stamp +Copy-Item $NginxConf $bak -Force +Write-Output ('bak=' + $bak) + +Write-Output '=== install release dist ===' +New-Item -ItemType Directory -Force -Path $RelDist | Out-Null +Expand-Archive -Path $IncomingZip -DestinationPath $RelDist -Force +if (-not (Test-Path (Join-Path $RelDist 'index.html'))) { + $inner = Get-ChildItem $RelDist -Directory | Select-Object -First 1 + if ($inner -and (Test-Path (Join-Path $inner.FullName 'index.html'))) { + $RelDist = $inner.FullName + } +} +if (-not (Test-Path (Join-Path $RelDist 'index.html'))) { throw 'index.html missing' } + +Write-Output '=== switch admin current junction ===' +if (Test-Path $Current) { + cmd /c ('rmdir "' + $Current + '"') | Out-Null + if (Test-Path $Current) { Remove-Item $Current -Recurse -Force } +} +cmd /c ('mklink /J "' + $Current + '" "' + $RelDist + '"') +if (-not (Test-Path (Join-Path $Current 'index.html'))) { throw 'current index missing' } +Write-Output ('CURRENT_OK=' + $Current + ' -> ' + $RelDist) + +Write-Output '=== nginx conf no BOM ===' +$bytes = [System.IO.File]::ReadAllBytes($IncomingConf) +if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) { + $list = New-Object System.Collections.Generic.List[byte] + for ($i = 3; $i -lt $bytes.Length; $i++) { [void]$list.Add($bytes[$i]) } + $bytes = $list.ToArray() +} +[System.IO.File]::WriteAllBytes($NginxConf, $bytes) + +Set-Location $Nginx +& .\nginx.exe -t +if ($LASTEXITCODE -ne 0) { + Copy-Item $bak $NginxConf -Force + throw 'nginx -t failed, restored' +} +Get-Process nginx -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 +try { + Start-Service Nginx1.18 -ErrorAction Stop + Write-Output 'SERVICE_STARTED' +} catch { + Start-Process -FilePath (Join-Path $Nginx 'nginx.exe') -WorkingDirectory $Nginx -WindowStyle Hidden + Write-Output 'EXE_STARTED' +} +Start-Sleep -Seconds 2 + +New-Item -ItemType Directory -Force -Path (Join-Path $Root 'ops') | Out-Null +$prevAgentCurrent = Join-Path $Root 'agent\current' +$prevAgentHome = $null +if (Test-Path $PtrPath) { + try { + $old = Get-Content $PtrPath -Raw | ConvertFrom-Json + if ($old.agentCurrent) { $prevAgentCurrent = [string]$old.agentCurrent } + if ($old.agentHome) { $prevAgentHome = [string]$old.agentHome } + } catch {} +} +$ptr = @{ + root = $Root + releaseId = $RelId + adminRelease = $RelDist + adminCurrent = $Current + agentCurrent = $prevAgentCurrent + agentHome = $prevAgentHome + nginxBak = $bak + jeecgbootProxy = 'http://127.0.0.1:8180/jeecgboot/' + updatedAt = (Get-Date).ToString('o') + note = 'admin cutover to Jeecg Vue + ledger 8180' +} +($ptr | ConvertTo-Json) | Out-File -FilePath $PtrPath -Encoding utf8 + +Write-Output '=== smoke ===' +try { + $h = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/cutover' -UseBasicParsing -TimeoutSec 8 + Write-Output ('ledger=' + $h.StatusCode) +} catch { + Write-Output ('ledger_fail=' + $_.Exception.Message) +} +Write-Output 'ADMIN_CUTOVER_OK' diff --git a/deploy/_remote_env_encoding_diag.ps1 b/deploy/_remote_env_encoding_diag.ps1 new file mode 100644 index 0000000..625499f --- /dev/null +++ b/deploy/_remote_env_encoding_diag.ps1 @@ -0,0 +1,52 @@ +# Diagnose E:\wwwroot\.env encoding without printing secret values +$ErrorActionPreference = 'Stop' +$path = 'E:\wwwroot\.env' +$bytes = [System.IO.File]::ReadAllBytes($path) +Write-Output ("bytes=" + $bytes.Length) +Write-Output ("bom_utf8=" + ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191)) +Write-Output ("bom_utf16le=" + ($bytes.Length -ge 2 -and $bytes[0] -eq 255 -and $bytes[1] -eq 254)) + +function Show-Head([string]$label, [string]$text) { + $lines = $text -split "`r?`n" | Select-Object -First 8 + Write-Output ("=== " + $label + " ===") + foreach ($line in $lines) { + if ($line -match '^(#|YTD_ENV=|PUBLIC_BASE_URL=|WECOM_CALLBACK_REPLY_TEXT=)') { + if ($line -match '^WECOM_CALLBACK_REPLY_TEXT=') { + $v = $line.Substring($line.IndexOf('=') + 1) + Write-Output ("WECOM_CALLBACK_REPLY_TEXT len=" + $v.Length + " sample=" + $v.Substring(0, [Math]::Min(24, $v.Length))) + } else { + Write-Output $line + } + } elseif ($line -match '^[A-Z0-9_]+=') { + $k = $line.Substring(0, $line.IndexOf('=')) + Write-Output ($k + "=***") + } else { + Write-Output $line + } + } +} + +$utf8 = [System.Text.Encoding]::UTF8.GetString($bytes) +Show-Head 'as_utf8' $utf8 + +$gbk = [System.Text.Encoding]::GetEncoding(936).GetString($bytes) +Show-Head 'as_gbk' $gbk + +# try: utf8 mis-decoded as gbk then saved as utf8 -> recover by encode gbk getbytes then utf8 getstring +try { + $rec1 = [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::GetEncoding(936).GetBytes($utf8)) + Show-Head 'recover_utf8_via_gbk_bytes' $rec1 +} catch { + Write-Output 'recover1_fail' +} + +# try: latin1/cp1252 roundtrip +try { + $latin = [System.Text.Encoding]::GetEncoding(1252).GetString($bytes) + $rec2 = [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::GetEncoding(1252).GetBytes($latin)) + Show-Head 'recover_cp1252_to_utf8' $rec2 +} catch { + Write-Output 'recover2_fail' +} + +Write-Output 'ENV_DIAG_DONE' diff --git a/deploy/_remote_find_lockers.ps1 b/deploy/_remote_find_lockers.ps1 new file mode 100644 index 0000000..36b7ccc --- /dev/null +++ b/deploy/_remote_find_lockers.ps1 @@ -0,0 +1,18 @@ +# Find who locks old agent release dirs +$ErrorActionPreference = 'Continue' +Write-Output '=== processes mentioning releases ===' +Get-CimInstance Win32_Process | ForEach-Object { + $cl = $_.CommandLine + if ($cl -and ($cl -like '*agent\\releases*' -or $cl -like '*agent/releases*' -or $cl -like '*wwwroot*agent*')) { + Write-Output ("pid=$($_.ProcessId) name=$($_.Name)") + Write-Output (" " + $cl.Substring(0, [Math]::Min(300, $cl.Length))) + } +} +Write-Output '=== netstat 8910 owner tree ===' +$p = (Get-NetTCPConnection -LocalPort 8910 -State Listen -EA SilentlyContinue | Select-Object -First 1).OwningProcess +if ($p) { + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p" + Write-Output ("listen_pid=$p parent=$($proc.ParentProcessId)") + Write-Output ($proc.CommandLine) +} +Write-Output 'LOCKERS_DONE' diff --git a/deploy/_remote_fix_env_encoding.ps1 b/deploy/_remote_fix_env_encoding.ps1 new file mode 100644 index 0000000..b2971ce --- /dev/null +++ b/deploy/_remote_fix_env_encoding.ps1 @@ -0,0 +1,85 @@ +# ASCII-only script: rewrite .env as UTF-8 BOM; Chinese via unicode escapes +$ErrorActionPreference = 'Stop' +$path = 'E:\wwwroot\.env' +$bak = 'E:\wwwroot\.env.bak-encoding-' + (Get-Date -Format 'yyyyMMdd-HHmmss') +Copy-Item $path $bak -Force +Write-Output ('backup=' + $bak) + +function U([string]$escaped) { + return [System.Text.RegularExpressions.Regex]::Unescape($escaped) +} + +$utf8BomEnc = New-Object System.Text.UTF8Encoding $true +$raw = [System.IO.File]::ReadAllText($path, $utf8BomEnc) + +$map = [ordered]@{} +foreach ($line in ($raw -split "`r?`n")) { + $t = $line.Trim() + if (-not $t -or $t.StartsWith('#')) { continue } + $i = $t.IndexOf('=') + if ($i -lt 1) { continue } + $k = $t.Substring(0, $i).Trim() + $v = $t.Substring($i + 1) + if (-not $map.Contains($k)) { $map[$k] = $v } +} + +# "询价智能体接通成功。已收到:{content}" +$goodReply = U '\u8be2\u4ef7\u667a\u80fd\u4f53\u63a5\u901a\u6210\u529f\u3002\u5df2\u6536\u5230\uff1a{content}' +$needFix = $true +if ($map.Contains('WECOM_CALLBACK_REPLY_TEXT')) { + $rt = [string]$map['WECOM_CALLBACK_REPLY_TEXT'] + if ($rt -match '\{content\}' -and $rt.Contains([char]0x8BE2) -and $rt.Contains([char]0x4EF7)) { + $needFix = $false + } +} +if ($needFix) { + $map['WECOM_CALLBACK_REPLY_TEXT'] = $goodReply + Write-Output 'fixed_reply_text' +} + +function V([string]$k, [string]$default = '') { + if ($map.Contains($k)) { return [string]$map[$k] } + return $default +} + +$lines = New-Object System.Collections.Generic.List[string] +$lines.Add((U '# \u53cb\u901a\u8fbe\u8be2\u4ef7\u667a\u80fd\u4f53 \u00b7 \u73af\u5883\u53d8\u91cf\uff08\u670d\u52a1\u5668 E:\\wwwroot\\.env\uff09')) +$lines.Add((U '# \u7f16\u7801\uff1aUTF-8\uff08\u5e26 BOM\uff0c\u4fbf\u4e8e\u8bb0\u4e8b\u672c\u6b63\u786e\u6253\u5f00\uff09\u3002\u7981\u6b62\u63d0\u4ea4 Git\u3002')) +$lines.Add((U '# \u5bf9\u7167\uff1aprompt/13-\u73af\u5883\u5bf9\u7167.md')) +$lines.Add('') +$lines.Add('YTD_ENV=' + (V 'YTD_ENV' 'test')) +$lines.Add('') +$lines.Add((U '# \u516c\u7f51\u4e0e\u4f01\u5fae\uff08\u57df\u540d\u4e0e\u6b63\u5f0f\u76f8\u540c\uff09')) +$lines.Add('PUBLIC_BASE_URL=' + (V 'PUBLIC_BASE_URL')) +$lines.Add('WECOM_CALLBACK_URL=' + (V 'WECOM_CALLBACK_URL')) +$lines.Add('INQUIRY_FORM_PUBLIC_BASE_URL=' + (V 'INQUIRY_FORM_PUBLIC_BASE_URL')) +$lines.Add('WECOM_CORP_ID=' + (V 'WECOM_CORP_ID')) +$lines.Add('WECOM_AGENT_ID=' + (V 'WECOM_AGENT_ID')) +$lines.Add('WECOM_SECRET=' + (V 'WECOM_SECRET')) +$lines.Add('WECOM_CALLBACK_TOKEN=' + (V 'WECOM_CALLBACK_TOKEN')) +$lines.Add('WECOM_ENCODING_AES_KEY=' + (V 'WECOM_ENCODING_AES_KEY')) +$lines.Add('WECOM_CALLBACK_REPLY_TEXT=' + (V 'WECOM_CALLBACK_REPLY_TEXT' $goodReply)) +$lines.Add('WECOM_OPS_AGENT_ID=' + (V 'WECOM_OPS_AGENT_ID')) +$lines.Add('DEV_INJECT_ENABLED=' + (V 'DEV_INJECT_ENABLED' 'false')) +$lines.Add('') +$lines.Add((U '# \u5176\u4f59\u914d\u7f6e\uff08\u7aef\u53e3 / \u5e93 / Redis / \u6a21\u578b\u7b49\uff09')) + +$written = @( + 'YTD_ENV','PUBLIC_BASE_URL','WECOM_CALLBACK_URL','INQUIRY_FORM_PUBLIC_BASE_URL', + 'WECOM_CORP_ID','WECOM_AGENT_ID','WECOM_SECRET','WECOM_CALLBACK_TOKEN', + 'WECOM_ENCODING_AES_KEY','WECOM_CALLBACK_REPLY_TEXT','WECOM_OPS_AGENT_ID','DEV_INJECT_ENABLED' +) +foreach ($k in $map.Keys) { + if ($written -contains $k) { continue } + $lines.Add($k + '=' + $map[$k]) +} + +$text = [string]::Join("`r`n", $lines.ToArray()) + "`r`n" +[System.IO.File]::WriteAllText($path, $text, $utf8BomEnc) + +$check = [System.IO.File]::ReadAllText($path, $utf8BomEnc) +$first = ($check -split "`r?`n")[0] +Write-Output ('first_line=' + $first) +Write-Output ('keys=' + $map.Count) +Write-Output ('reply_has_cn=' + ($check.Contains([char]0x8BE2) -and $check.Contains('{content}'))) +Write-Output 'ENV_REWRITE_OK' diff --git a/deploy/_remote_fix_pointer.ps1 b/deploy/_remote_fix_pointer.ps1 new file mode 100644 index 0000000..254bc24 --- /dev/null +++ b/deploy/_remote_fix_pointer.ps1 @@ -0,0 +1,17 @@ +# Fix active-release pointer after admin cutover (ASCII only) +$ErrorActionPreference = 'Stop' +New-Item -ItemType Directory -Force -Path 'E:\wwwroot\ops' | Out-Null +$obj = [ordered]@{ + root = 'E:\wwwroot' + releaseId = 'admin-20260913-154002' + adminCurrent = 'E:\wwwroot\admin\current' + adminRelease = 'E:\wwwroot\admin\releases\admin-20260913-154002\dist' + agentCurrent = 'E:\wwwroot\agent\current' + agentHome = 'E:\wwwroot\agent\releases\agent-20260913-143916\agent' + jeecgbootProxy = 'http://127.0.0.1:8180/jeecgboot/' + updatedAt = (Get-Date).ToString('o') + note = 'admin cutover Jeecg Vue + ledger 8180' +} +($obj | ConvertTo-Json) | Out-File -FilePath 'E:\wwwroot\ops\active-release.json' -Encoding utf8 +Get-Content 'E:\wwwroot\ops\active-release.json' +Write-Output 'POINTER_OK' diff --git a/deploy/_remote_force_clean_agent.ps1 b/deploy/_remote_force_clean_agent.ps1 new file mode 100644 index 0000000..fc58c82 --- /dev/null +++ b/deploy/_remote_force_clean_agent.ps1 @@ -0,0 +1,34 @@ +# Force remove stale agent releases (not current) +$ErrorActionPreference = 'Continue' +$keepName = 'agent-20260913-210940' +Get-ChildItem 'E:\wwwroot\agent\releases' | Where-Object { $_.PSIsContainer } | ForEach-Object { + $name = $_.Name + $full = $_.FullName + if ($name -eq $keepName) { + Write-Output ('KEEP ' + $name) + return + } + Write-Output ('CLEAN ' + $name) + Get-CimInstance Win32_Process | ForEach-Object { + $cl = $_.CommandLine + if ($cl -and $cl.Contains($full)) { + Write-Output ('kill pid=' + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + } + Start-Sleep -Seconds 1 + cmd /c ("rmdir /s /q `"$full`"") | Out-Null + if (Test-Path $full) { + try { + Remove-Item $full -Recurse -Force -ErrorAction Stop + Write-Output ('REMOVED_PS ' + $name) + } catch { + Write-Output ('FAIL ' + $name + ' ' + $_.Exception.Message) + } + } else { + Write-Output ('REMOVED ' + $name) + } +} +Write-Output '=== left ===' +Get-ChildItem 'E:\wwwroot\agent\releases' | ForEach-Object { $_.Name } +Write-Output 'FORCE_CLEAN_OK' diff --git a/deploy/_remote_force_env_utf8.ps1 b/deploy/_remote_force_env_utf8.ps1 new file mode 100644 index 0000000..4d2b5cc --- /dev/null +++ b/deploy/_remote_force_env_utf8.ps1 @@ -0,0 +1,104 @@ +# Force true UTF-8 body (not GBK) with BOM; ASCII-only script +$ErrorActionPreference = 'Stop' +$path = 'E:\wwwroot\.env' +$bak = 'E:\wwwroot\.env.bak-encoding2-' + (Get-Date -Format 'yyyyMMdd-HHmmss') +Copy-Item $path $bak -Force + +function U([string]$escaped) { + return [System.Text.RegularExpressions.Regex]::Unescape($escaped) +} + +# Read previous values using BOTH encodings; prefer lines that parse as KEY=VALUE ascii +function Load-Map([byte[]]$bytes) { + $map = [ordered]@{} + foreach ($encName in @('utf-8', 'gbk')) { + $enc = if ($encName -eq 'utf-8') { New-Object System.Text.UTF8Encoding $false } else { [System.Text.Encoding]::GetEncoding(936) } + $start = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) { $start = 3 } + $text = $enc.GetString($bytes, $start, $bytes.Length - $start) + foreach ($line in ($text -split "`r?`n")) { + $t = $line.Trim() + if (-not $t -or $t.StartsWith('#')) { continue } + $i = $t.IndexOf('=') + if ($i -lt 1) { continue } + $k = $t.Substring(0, $i).Trim() + $v = $t.Substring($i + 1) + if ($k -notmatch '^[A-Z0-9_]+$') { continue } + if (-not $map.Contains($k)) { $map[$k] = $v } + } + } + return $map +} + +$bytes = [System.IO.File]::ReadAllBytes($path) +$map = Load-Map $bytes +Write-Output ('keys=' + $map.Count) + +$goodReply = U '\u8be2\u4ef7\u667a\u80fd\u4f53\u63a5\u901a\u6210\u529f\u3002\u5df2\u6536\u5230\uff1a{content}' +$map['WECOM_CALLBACK_REPLY_TEXT'] = $goodReply + +function V([string]$k, [string]$d = '') { + if ($map.Contains($k)) { return [string]$map[$k] } + return $d +} + +$lines = New-Object System.Collections.Generic.List[string] +$lines.Add((U '# \u53cb\u901a\u8fbe\u8be2\u4ef7\u667a\u80fd\u4f53 \u00b7 \u73af\u5883\u53d8\u91cf\uff08E:\\wwwroot\\.env\uff09')) +$lines.Add((U '# \u7f16\u7801 UTF-8 BOM\uff1b\u8bb0\u4e8b\u672c\u8bf7\u9009 UTF-8\u3002\u7981\u6b62\u63d0\u4ea4 Git\u3002')) +$lines.Add((U '# \u5bf9\u7167 prompt/13')) +$lines.Add('') +$lines.Add('YTD_ENV=' + (V 'YTD_ENV' 'test')) +$lines.Add('') +$lines.Add((U '# \u516c\u7f51\u4e0e\u4f01\u5fae')) +$lines.Add('PUBLIC_BASE_URL=' + (V 'PUBLIC_BASE_URL')) +$lines.Add('WECOM_CALLBACK_URL=' + (V 'WECOM_CALLBACK_URL')) +$lines.Add('INQUIRY_FORM_PUBLIC_BASE_URL=' + (V 'INQUIRY_FORM_PUBLIC_BASE_URL')) +$lines.Add('WECOM_CORP_ID=' + (V 'WECOM_CORP_ID')) +$lines.Add('WECOM_AGENT_ID=' + (V 'WECOM_AGENT_ID')) +$lines.Add('WECOM_SECRET=' + (V 'WECOM_SECRET')) +$lines.Add('WECOM_CALLBACK_TOKEN=' + (V 'WECOM_CALLBACK_TOKEN')) +$lines.Add('WECOM_ENCODING_AES_KEY=' + (V 'WECOM_ENCODING_AES_KEY')) +$lines.Add('WECOM_CALLBACK_REPLY_TEXT=' + $goodReply) +$lines.Add('WECOM_OPS_AGENT_ID=' + (V 'WECOM_OPS_AGENT_ID')) +$lines.Add('DEV_INJECT_ENABLED=' + (V 'DEV_INJECT_ENABLED' 'false')) +$lines.Add('') +$lines.Add((U '# \u5176\u4f59\u914d\u7f6e')) +$skip = @('YTD_ENV','PUBLIC_BASE_URL','WECOM_CALLBACK_URL','INQUIRY_FORM_PUBLIC_BASE_URL','WECOM_CORP_ID','WECOM_AGENT_ID','WECOM_SECRET','WECOM_CALLBACK_TOKEN','WECOM_ENCODING_AES_KEY','WECOM_CALLBACK_REPLY_TEXT','WECOM_OPS_AGENT_ID','DEV_INJECT_ENABLED') +foreach ($k in $map.Keys) { + if ($skip -contains $k) { continue } + $lines.Add($k + '=' + $map[$k]) +} + +$text = [string]::Join("`r`n", $lines.ToArray()) + "`r`n" + +# Write UTF-8 BOM explicitly via bytes +$utf8 = New-Object System.Text.UTF8Encoding $false +$body = $utf8.GetBytes($text) +$bom = [byte[]](0xEF, 0xBB, 0xBF) +$all = New-Object byte[] ($bom.Length + $body.Length) +[Array]::Copy($bom, 0, $all, 0, 3) +[Array]::Copy($body, 0, $all, 3, $body.Length) +[System.IO.File]::WriteAllBytes($path, $all) + +# Prove first Chinese char "友" is UTF-8 E5 8F 8B not GBK D3 D1 +$nb = [System.IO.File]::ReadAllBytes($path) +# find first non-ascii after BOM +$i = 3 +while ($i -lt $nb.Length -and $nb[$i] -lt 128) { $i++ } +$hex = ($nb[$i..([Math]::Min($i+2, $nb.Length-1))] | ForEach-Object { $_.ToString('X2') }) -join ' ' +Write-Output ('first_non_ascii_hex=' + $hex) +Write-Output ('expect_utf8_you=E5 8F 8B') +Write-Output ('is_utf8_you=' + ($hex -eq 'E5 8F 8B')) + +# decode check without console encoding issues: look for UTF-8 sequence of 询 +$needle = $utf8.GetBytes((U '\u8be2\u4ef7')) +$found = $false +for ($p = 0; $p -le $nb.Length - $needle.Length; $p++) { + $ok = $true + for ($j = 0; $j -lt $needle.Length; $j++) { + if ($nb[$p + $j] -ne $needle[$j]) { $ok = $false; break } + } + if ($ok) { $found = $true; break } +} +Write-Output ('has_utf8_xunjia=' + $found) +Write-Output 'ENV_UTF8_OK' diff --git a/deploy/_remote_ledger_diag.ps1 b/deploy/_remote_ledger_diag.ps1 new file mode 100644 index 0000000..ea18f6c --- /dev/null +++ b/deploy/_remote_ledger_diag.ps1 @@ -0,0 +1,34 @@ +# 读取主账启动失败原因(不打印密钥) +$ErrorActionPreference = 'Continue' +$Err = 'E:\wwwroot\inquiry-new\logs\stderr.log' +$Out = 'E:\wwwroot\inquiry-new\logs\stdout.log' +Write-Output '=== process ===' +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -like '*inquiry-new*') { + "pid=$($_.ProcessId) alive" + } +} +Write-Output '=== port 8180 ===' +$c = Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if ($c) { "LISTEN pid=$($c.OwningProcess)" } else { 'down' } + +Write-Output '=== stderr tail ===' +if (Test-Path $Err) { Get-Content $Err -Tail 80 } else { 'no stderr' } +Write-Output '=== stdout tail ===' +if (Test-Path $Out) { Get-Content $Out -Tail 80 } else { 'no stdout' } + +Write-Output '=== try health variants ===' +foreach ($u in @( + 'http://127.0.0.1:8180/jeecgboot/', + 'http://127.0.0.1:8180/jeecg-boot/', + 'http://127.0.0.1:8180/actuator/health', + 'http://127.0.0.1:8180/' +)) { + try { + $r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 5 + "OK $($r.StatusCode) $u" + } catch { + "FAIL $u" + } +} +Write-Output 'LEDGER_DIAG_DONE' diff --git a/deploy/_remote_ledger_diag2.ps1 b/deploy/_remote_ledger_diag2.ps1 new file mode 100644 index 0000000..ea27f27 --- /dev/null +++ b/deploy/_remote_ledger_diag2.ps1 @@ -0,0 +1,16 @@ +# 主账 8180 掉线诊断 +$ErrorActionPreference = 'Continue' +Write-Output '=== java inquiry-new ===' +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -like '*inquiry-new*' -or $_.CommandLine -like '*8180*') { + "pid=$($_.ProcessId) $($_.CommandLine.Substring(0, [Math]::Min(180, $_.CommandLine.Length)))" + } +} +Write-Output '=== recent stdout error lines ===' +Select-String -Path 'E:\wwwroot\inquiry-new\logs\stdout.log' -Pattern 'ERROR|Exception|Started Jeecg|Tomcat started|APPLICATION FAILED' | + Select-Object -Last 20 | + ForEach-Object { $_.Line.Substring(0, [Math]::Min(220, $_.Line.Length)) } +Write-Output '=== file times ===' +Get-Item 'E:\wwwroot\inquiry-new\logs\stdout.log','E:\wwwroot\inquiry-new\logs\stderr.log' | + ForEach-Object { "$($_.Name) len=$($_.Length) mtime=$($_.LastWriteTime)" } +Write-Output 'DIAG2_DONE' diff --git a/deploy/_remote_ledger_tail.ps1 b/deploy/_remote_ledger_tail.ps1 new file mode 100644 index 0000000..fdd72aa --- /dev/null +++ b/deploy/_remote_ledger_tail.ps1 @@ -0,0 +1,6 @@ +# 抓主账 stdout 里最后一次失败的根因 +$ErrorActionPreference = 'Continue' +$log = 'E:\wwwroot\inquiry-new\logs\stdout.log' +Write-Output '=== last 80 ===' +Get-Content $log -Tail 80 +Write-Output 'TAIL_DONE' diff --git a/deploy/_remote_list_releases.ps1 b/deploy/_remote_list_releases.ps1 new file mode 100644 index 0000000..7e7059d --- /dev/null +++ b/deploy/_remote_list_releases.ps1 @@ -0,0 +1,40 @@ +# List wwwroot layout for cleanup decision +$ErrorActionPreference = 'Continue' +Write-Output '=== tree tops ===' +@( + 'E:\wwwroot', + 'E:\wwwroot\agent', + 'E:\wwwroot\admin', + 'E:\wwwroot\inquiry-new', + 'E:\wwwroot\ops', + 'E:\wwwroot\agent\current', + 'E:\wwwroot\admin\current' +) | ForEach-Object { "$_ => $(Test-Path $_)" } + +Write-Output '=== agent releases ===' +if (Test-Path 'E:\wwwroot\agent\releases') { + Get-ChildItem 'E:\wwwroot\agent\releases' | ForEach-Object { $_.Name + ' ' + $_.LastWriteTime } +} else { 'missing agent/releases' } + +Write-Output '=== admin releases ===' +if (Test-Path 'E:\wwwroot\admin\releases') { + Get-ChildItem 'E:\wwwroot\admin\releases' | ForEach-Object { $_.Name + ' ' + $_.LastWriteTime } +} else { 'missing admin/releases' } + +Write-Output '=== current targets ===' +foreach ($p in @('E:\wwwroot\agent\current','E:\wwwroot\admin\current')) { + if (Test-Path $p) { + $i = Get-Item $p -Force + "link=$p type=$($i.LinkType) target=$($i.Target)" + } else { "missing $p" } +} + +Write-Output '=== pointer ===' +if (Test-Path 'E:\wwwroot\ops\active-release.json') { Get-Content 'E:\wwwroot\ops\active-release.json' -Raw } + +Write-Output '=== ports ===' +foreach ($x in 8080,8810,8813,8180,8910,8913) { + $c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1 + if ($c) { "$x LISTEN pid=$($c.OwningProcess)" } else { "$x down" } +} +Write-Output 'LIST_DONE' diff --git a/deploy/_remote_login_captcha_check.ps1 b/deploy/_remote_login_captcha_check.ps1 new file mode 100644 index 0000000..c537e1d --- /dev/null +++ b/deploy/_remote_login_captcha_check.ps1 @@ -0,0 +1,26 @@ +# Check captcha flag on running ledger + try login without captcha +$ErrorActionPreference = 'Continue' +Write-Output '=== overlay jeecg.loginCaptcha ===' +Select-String -Path 'E:\wwwroot\inquiry-new\config\application-inquiry.yml' -Pattern 'loginCaptcha|cas:' | ForEach-Object { $_.Line } +Write-Output '=== process cmdline ===' +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -like '*inquiry-new*') { + $_.CommandLine.Substring(0, [Math]::Min(300, $_.CommandLine.Length)) + } +} +Write-Output '=== login attempt ===' +$body = '{"username":"admin","password":"123456","captcha":"","checkKey":""}' +try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -Body $body -ContentType 'application/json;charset=UTF-8' -UseBasicParsing -TimeoutSec 15 + "status=$($r.StatusCode) body=$($r.Content.Substring(0,[Math]::Min(300,$r.Content.Length)))" +} catch { + $resp = $_.Exception.Response + if ($resp) { + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + $txt = $reader.ReadToEnd() + "http_err status=$([int]$resp.StatusCode) body=$txt" + } else { + "err=$($_.Exception.Message)" + } +} +Write-Output 'CAPTCHA_CHECK_DONE' diff --git a/deploy/_remote_post_admin_cut.ps1 b/deploy/_remote_post_admin_cut.ps1 new file mode 100644 index 0000000..e5dc020 --- /dev/null +++ b/deploy/_remote_post_admin_cut.ps1 @@ -0,0 +1,27 @@ +# 切后台后验收:静态是否为 Jeecg、API 是否走 8180 +$ErrorActionPreference = 'Continue' +Write-Output '=== admin index sniff ===' +$idx = 'E:\wwwroot\admin\current\index.html' +if (Test-Path $idx) { + Get-Content $idx -TotalCount 30 +} +Write-Output '=== app config ===' +$cfg = Get-ChildItem 'E:\wwwroot\admin\current' -Filter '_app.config.js' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($cfg) { + Select-String -Path $cfg.FullName -Pattern 'VITE_GLOB|jeecgboot|8080|8180|ai.ytd' | ForEach-Object { $_.Line.Substring(0, [Math]::Min(200, $_.Line.Length)) } +} +Write-Output '=== nginx targets ===' +Select-String -Path 'C:\nginx-1.18.0\myconf\ai.conf' -Pattern 'proxy_pass|root ' | ForEach-Object { $_.Line.Trim() } +Write-Output '=== ports ===' +foreach ($x in 8080,8180,8910) { + $c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1 + if ($c) { "$x LISTEN" } else { "$x down" } +} +Write-Output '=== local proxy smoke ===' +try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/aftercut' -UseBasicParsing -TimeoutSec 8 + "8180 ok $($r.StatusCode)" +} catch { "8180 fail" } +Write-Output '=== pointer ===' +Get-Content 'E:\wwwroot\ops\active-release.json' -Raw +Write-Output 'POST_CUT_OK' diff --git a/deploy/_remote_probe_agent_logs.ps1 b/deploy/_remote_probe_agent_logs.ps1 new file mode 100644 index 0000000..88826cb --- /dev/null +++ b/deploy/_remote_probe_agent_logs.ps1 @@ -0,0 +1,45 @@ +# 探测新系统智能体 8910 日志落点(只读,不改现网) +$ErrorActionPreference = 'Continue' +Write-Output '=== 8910 listen ===' +$c = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($c) { + Write-Output ("LISTEN pid=" + $c.OwningProcess) +} else { + Write-Output '8910 not listening' +} + +Write-Output '=== health ===' +try { + $h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5 + Write-Output $h.Content +} catch { + Write-Output ('health_fail: ' + $_.Exception.Message) +} + +Write-Output '=== candidate log dirs ===' +$dirs = @( + 'E:\wwwroot\logs', + 'E:\wwwroot\agent\logs', + 'E:\wwwroot\agent\current', + 'C:\ytd-test\logs' +) +foreach ($d in $dirs) { + if (Test-Path $d) { + Write-Output ("DIR_OK " + $d) + Get-ChildItem $d -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match 'agent|wecom|http|worker' } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 20 | + ForEach-Object { Write-Output (" " + $_.FullName + " bytes=" + $_.Length + " mtime=" + $_.LastWriteTime.ToString('s')) } + } else { + Write-Output ("DIR_MISSING " + $d) + } +} + +Write-Output '=== python cmdlines matching wwwroot/agent ===' +Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue | ForEach-Object { + $cl = $_.CommandLine + if ($null -ne $cl -and ($cl -match 'wwwroot|agent\.http_app|8910')) { + Write-Output ("pid=" + $_.ProcessId + " " + $cl) + } +} diff --git a/deploy/_remote_quick_8180.ps1 b/deploy/_remote_quick_8180.ps1 new file mode 100644 index 0000000..f605d8e --- /dev/null +++ b/deploy/_remote_quick_8180.ps1 @@ -0,0 +1,20 @@ +# 快速看 8180 是否还活着 +$ErrorActionPreference = 'Continue' +$c = Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if ($c) { "8180 LISTEN pid=$($c.OwningProcess)" } else { '8180 down' } +$p8080 = Get-NetTCPConnection -LocalPort 8080 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if ($p8080) { "8080 LISTEN pid=$($p8080.OwningProcess)" } else { '8080 down' } +$p8910 = Get-NetTCPConnection -LocalPort 8910 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if ($p8910) { "8910 LISTEN pid=$($p8910.OwningProcess)" } else { '8910 down' } +try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/deploycheck' -UseBasicParsing -TimeoutSec 8 + "8180_randomImage=$($r.StatusCode) bytes=$($r.RawContentLength)" +} catch { + $code = $null + try { $code = $_.Exception.Response.StatusCode.value__ } catch {} + "8180_randomImage FAIL code=$code msg=$($_.Exception.Message)" +} +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -like '*inquiry-new*') { "java pid=$($_.ProcessId) alive" } +} +Write-Output 'QUICK_DONE' diff --git a/deploy/_remote_redis_auth_check.ps1 b/deploy/_remote_redis_auth_check.ps1 new file mode 100644 index 0000000..9f13939 --- /dev/null +++ b/deploy/_remote_redis_auth_check.ps1 @@ -0,0 +1,62 @@ +# 用本机 Python 测 Redis AUTH(只打印 PONG/错误类型,不打印口令) +$ErrorActionPreference = 'Stop' +Get-Content 'E:\wwwroot\.env' | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + $i = $line.IndexOf('=') + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0,$i).Trim()) -Value $line.Substring($i+1).Trim().Trim('"') +} +$py = 'C:\yutongda\tools\Python312\python.exe' +$code = @' +import os, socket, sys +url = os.environ.get("REDIS_URL","") +host = os.environ.get("YTD_REDIS_HOST","10.206.0.14") +port = int(os.environ.get("YTD_REDIS_PORT","6379")) +user = os.environ.get("YTD_REDIS_USERNAME","") +pwd = os.environ.get("YTD_REDIS_PASSWORD","") +# parse url if needed +if url.startswith("redis://"): + rest = url[len("redis://"):] + if "@" in rest: + cred, hp = rest.split("@",1) + if ":" in cred: + u,p = cred.split(":",1) + if u: user = u + pwd = p + else: + pwd = cred + host = hp.split("/")[0].split(":")[0] + if ":" in hp.split("/")[0]: + port = int(hp.split("/")[0].split(":")[1]) + +def send(auth_cmd): + s = socket.create_connection((host, port), 5) + s.sendall(auth_cmd) + data = s.recv(256) + s.close() + return data + +# RESP AUTH +def resp_auth_password(p): + p = p.encode() + return b"*2\r\n$4\r\nAUTH\r\n$%d\r\n%s\r\n" % (len(p), p) + +def resp_auth_user(u, p): + u=u.encode(); p=p.encode() + return b"*3\r\n$4\r\nAUTH\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n" % (len(u), u, len(p), p) + +print("host=%s port=%s user_len=%s pwd_len=%s" % (host, port, len(user or ""), len(pwd or ""))) +try: + r1 = send(resp_auth_password(pwd or "")) + print("auth_password:", r1[:80]) +except Exception as e: + print("auth_password_err", type(e).__name__, str(e)[:80]) +if user: + try: + r2 = send(resp_auth_user(user, pwd or "")) + print("auth_user:", r2[:80]) + except Exception as e: + print("auth_user_err", type(e).__name__, str(e)[:80]) +'@ +$code | & $py - diff --git a/deploy/_remote_redis_probe.ps1 b/deploy/_remote_redis_probe.ps1 new file mode 100644 index 0000000..59d3b01 --- /dev/null +++ b/deploy/_remote_redis_probe.ps1 @@ -0,0 +1,45 @@ +# Redis connectivity probe (no secrets printed) +$ErrorActionPreference = 'Continue' +Get-Content 'E:\wwwroot\.env' | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + $i = $line.IndexOf('=') + if ($i -lt 1) { return } + $k = $line.Substring(0, $i).Trim() + $v = $line.Substring($i + 1).Trim().Trim('"') + if ($k -match 'REDIS|YTD_REDIS') { + if ($k -match 'PASSWORD|URL') { + Write-Output ("$k len=" + $v.Length + " has_at=" + ($v -match '@')) + } else { + Write-Output ("$k=$v") + } + } +} + +Write-Output '=== local 6379 ===' +$c = Get-NetTCPConnection -LocalPort 6379 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if ($c) { "127.0.0.1:6379 LISTEN pid=$($c.OwningProcess)" } else { 'local 6379 down' } + +$redisCli = @( + 'C:\yutongda\tools\Redis\redis-cli.exe', + 'C:\Program Files\Redis\redis-cli.exe', + 'E:\wwwroot\tools\redis-cli.exe' +) | Where-Object { Test-Path $_ } | Select-Object -First 1 + +Write-Output ("redis-cli=" + $redisCli) + +# try ping local without auth +if ($redisCli) { + & $redisCli -h 127.0.0.1 -p 6379 PING 2>&1 | ForEach-Object { "local_nopass: $_" } +} + +# try data host from env if present +$hostLine = (Get-Content 'E:\wwwroot\.env' | Where-Object { $_ -match '^YTD_REDIS_HOST=' } | Select-Object -First 1) +if ($hostLine) { + $rh = $hostLine.Split('=',2)[1].Trim().Trim('"') + Write-Output ("env_host=$rh") + if ($redisCli -and $rh -and $rh -ne '127.0.0.1') { + & $redisCli -h $rh -p 6379 PING 2>&1 | ForEach-Object { "remote_nopass: $_" } + } +} +Write-Output 'REDIS_PROBE_DONE' diff --git a/deploy/_remote_redis_tcp.ps1 b/deploy/_remote_redis_tcp.ps1 new file mode 100644 index 0000000..d34f86c --- /dev/null +++ b/deploy/_remote_redis_tcp.ps1 @@ -0,0 +1,29 @@ +# TCP 探测 Redis,不打印口令 +$ErrorActionPreference = 'Continue' +function Test-Port($h, $p) { + try { + $tcp = New-Object System.Net.Sockets.TcpClient + $iar = $tcp.BeginConnect($h, [int]$p, $null, $null) + $ok = $iar.AsyncWaitHandle.WaitOne(3000, $false) + if (-not $ok) { $tcp.Close(); return "TIMEOUT $h`:$p" } + $tcp.EndConnect($iar) + $tcp.Close() + return "OPEN $h`:$p" + } catch { + return "FAIL $h`:$p $($_.Exception.Message)" + } +} +Write-Output (Test-Port '127.0.0.1' 6379) +Write-Output (Test-Port '10.206.0.14' 6379) +Write-Output (Test-Port '10.206.0.15' 6379) +Write-Output (Test-Port '10.206.0.14' 3306) + +# 从 REDIS_URL 解析 host +$raw = (Get-Content 'E:\wwwroot\.env' | Where-Object { $_ -match '^REDIS_URL=' } | Select-Object -First 1) +if ($raw) { + if ($raw -match '@([^:/]+):(\d+)/(\d+)') { + Write-Output ("url_host=" + $Matches[1] + " port=" + $Matches[2] + " db=" + $Matches[3]) + Write-Output (Test-Port $Matches[1] $Matches[2]) + } +} +Write-Output 'TCP_DONE' diff --git a/deploy/_remote_repair_agent_cleanup.ps1 b/deploy/_remote_repair_agent_cleanup.ps1 new file mode 100644 index 0000000..fe3452d --- /dev/null +++ b/deploy/_remote_repair_agent_cleanup.ps1 @@ -0,0 +1,90 @@ +# Kill duplicate agent wrappers; keep newest; then remove stale release dirs +$ErrorActionPreference = 'Continue' +$Root = 'E:\wwwroot' +$keepRel = 'agent-20260913-210940' +$keepHome = Join-Path $Root ("agent\releases\" + $keepRel + "\agent") + +Write-Output '=== stop ALL agent cmd/python under wwwroot ===' +Get-CimInstance Win32_Process | ForEach-Object { + $cl = $_.CommandLine + if (-not $cl) { return } + $hit = $false + if ($cl -like '*run-agent-http.cmd*' -or $cl -like '*run-agent-worker.cmd*') { $hit = $true } + if ($cl -like '*agent.http_app*' -or $cl -like '*agent.worker_app*') { $hit = $true } + if ($cl -like '*wwwroot\agent*') { $hit = $true } + if ($hit) { + Write-Output ("stop pid=$($_.ProcessId) $($_.Name)") + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +# also free 8910 +Get-NetTCPConnection -LocalPort 8910 -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } +Start-Sleep -Seconds 3 + +Write-Output '=== delete stale releases ===' +Get-ChildItem (Join-Path $Root 'agent\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object { + if ($_.Name -eq $keepRel) { + Write-Output ('KEEP ' + $_.Name) + } else { + $full = $_.FullName + cmd /c ("rmdir /s /q `"$full`"") | Out-Null + if (Test-Path $full) { + Write-Output ('STILL ' + $_.Name) + } else { + Write-Output ('GONE ' + $_.Name) + } + } +} + +Write-Output '=== rewrite run scripts to keepHome ===' +$venvPy = Join-Path $keepHome '.venv\Scripts\python.exe' +$runHttp = @( + '@echo off', + ('cd /d "' + $keepHome + '"'), + 'set YTD_ENV=test', + 'set AGENT_HTTP_PORT=8910', + 'set PYTHONUTF8=1', + ':loop', + ('"' + $venvPy + '" -m agent.http_app >> "' + $Root + '\logs\agent-http.out.log" 2>> "' + $Root + '\logs\agent-http.err.log"'), + ('echo agent exited >> "' + $Root + '\logs\agent-http.err.log"'), + 'timeout /t 3 /nobreak >nul', + 'goto loop' +) -join "`r`n" +Set-Content -Path (Join-Path $Root 'ops\run-agent-http.cmd') -Value $runHttp -Encoding ASCII + +$runWorker = @( + '@echo off', + ('cd /d "' + $keepHome + '"'), + 'set YTD_ENV=test', + 'set PYTHONUTF8=1', + ':loop', + ('"' + $venvPy + '" -m agent.worker_app >> "' + $Root + '\logs\agent-worker.out.log" 2>> "' + $Root + '\logs\agent-worker.err.log"'), + ('echo worker exited >> "' + $Root + '\logs\agent-worker.err.log"'), + 'timeout /t 3 /nobreak >nul', + 'goto loop' +) -join "`r`n" +Set-Content -Path (Join-Path $Root 'ops\run-agent-worker.cmd') -Value $runWorker -Encoding ASCII + +# ensure junction +$current = Join-Path $Root 'agent\current' +if (Test-Path $current) { cmd /c ("rmdir `"$current`"") | Out-Null } +cmd /c ("mklink /J `"$current`" `"$keepHome`"") | Out-Null + +Write-Output '=== start one http + one worker ===' +$w1 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('cmd.exe /c "' + (Join-Path $Root 'ops\run-agent-http.cmd') + '"') } +$w2 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('cmd.exe /c "' + (Join-Path $Root 'ops\run-agent-worker.cmd') + '"') } +Write-Output ("http_wmi=" + $w1.ReturnValue + " worker_wmi=" + $w2.ReturnValue) +Start-Sleep -Seconds 6 +try { + $h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 8 + Write-Output ('HEALTH=' + $h.StatusCode + ' ' + $h.Content) +} catch { + Write-Output ('health_fail ' + $_) + Get-Content (Join-Path $Root 'logs\agent-http.err.log') -Tail 30 -ErrorAction SilentlyContinue +} + +Write-Output '=== left releases ===' +Get-ChildItem (Join-Path $Root 'agent\releases') | ForEach-Object { $_.Name } +Write-Output 'REPAIR_OK' diff --git a/deploy/_remote_safe_deploy_agent.ps1 b/deploy/_remote_safe_deploy_agent.ps1 index 9023fcc..52358e2 100644 --- a/deploy/_remote_safe_deploy_agent.ps1 +++ b/deploy/_remote_safe_deploy_agent.ps1 @@ -1,33 +1,28 @@ -# 安全发布:新 release + 切 agent\current,重启 8910;禁止改 Nginx / C:\yutongda -# 入参:incoming\agent-release.zip 已就位;可选 env.file 覆盖 E:\wwwroot\.env +# Safe agent deploy: new release, switch junction, restart 8910+worker. No nginx / no C:\yutongda. $ErrorActionPreference = 'Stop' $ReleaseId = 'agent-' + (Get-Date -Format 'yyyyMMdd-HHmmss') $Root = 'E:\wwwroot' -$Release = Join-Path $Root "agent\releases\$ReleaseId" +$Release = Join-Path $Root ("agent\releases\" + $ReleaseId) $AgentCurrent = Join-Path $Root 'agent\current' $Python = 'C:\yutongda\tools\Python312\python.exe' $Zip = 'C:\Users\ytd_extdev_0903\incoming\agent-release.zip' -$EnvSrc = 'C:\Users\ytd_extdev_0903\incoming\env.file' -Write-Output "=== create dirs release=$ReleaseId ===" -New-Item -ItemType Directory -Force -Path $Release, "$Root\logs", "$Root\ops", "$Root\agent\releases" | Out-Null -if (-not (Test-Path $Zip)) { throw "missing $Zip" } -if (-not (Test-Path $Python)) { throw "missing $Python" } +Write-Output ("=== create dirs release=" + $ReleaseId + " ===") +New-Item -ItemType Directory -Force -Path $Release, (Join-Path $Root 'logs'), (Join-Path $Root 'ops'), (Join-Path $Root 'agent\releases') | Out-Null +if (-not (Test-Path $Zip)) { throw 'missing agent-release.zip' } +if (-not (Test-Path $Python)) { throw 'missing python' } Write-Output '=== expand ===' Expand-Archive -Path $Zip -DestinationPath $Release -Force $AgentHome = Join-Path $Release 'agent' -if (-not (Test-Path (Join-Path $AgentHome 'agent\http_app.py'))) { - throw "http_app.py not found under $AgentHome" -} +$HttpApp = Join-Path $AgentHome 'agent\http_app.py' +if (-not (Test-Path $HttpApp)) { throw 'http_app.py not found' } Write-Output '=== env ===' -if (Test-Path $EnvSrc) { - Copy-Item $EnvSrc (Join-Path $Root '.env') -Force -} -if (-not (Test-Path (Join-Path $Root '.env'))) { throw 'missing E:\wwwroot\.env' } -Copy-Item (Join-Path $Root '.env') (Join-Path $Release '.env') -Force -Copy-Item (Join-Path $Root '.env') (Join-Path $AgentHome '.env') -Force +$EnvFile = Join-Path $Root '.env' +if (-not (Test-Path $EnvFile)) { throw 'missing E:\wwwroot\.env' } +Copy-Item $EnvFile (Join-Path $Release '.env') -Force +Copy-Item $EnvFile (Join-Path $AgentHome '.env') -Force Write-Output '=== venv + pip ===' $venvDir = Join-Path $AgentHome '.venv' @@ -37,93 +32,111 @@ if (-not (Test-Path $venvPy)) { & $Python -m venv $venvDir if ($LASTEXITCODE -ne 0) { throw 'venv failed' } } -& $venvPip install --default-timeout=180 -i https://pypi.tuna.tsinghua.edu.cn/simple -r (Join-Path $AgentHome 'requirements.txt') +# Windows Chinese locale: force UTF-8 so requirements.txt comments decode +$env:PYTHONUTF8 = '1' +$env:PYTHONIOENCODING = 'utf-8' +$Req = Join-Path $AgentHome 'requirements.txt' +& $venvPip install --default-timeout=180 -i https://pypi.tuna.tsinghua.edu.cn/simple -r $Req if ($LASTEXITCODE -ne 0) { throw 'pip install failed' } Write-Output '=== switch junction ===' if (Test-Path $AgentCurrent) { - cmd /c "rmdir `"$AgentCurrent`"" | Out-Null + cmd /c ("rmdir `"" + $AgentCurrent + "`"") | Out-Null if (Test-Path $AgentCurrent) { Remove-Item $AgentCurrent -Recurse -Force } } -cmd /c "mklink /J `"$AgentCurrent`" `"$AgentHome`"" +cmd /c ("mklink /J `"" + $AgentCurrent + "`" `"" + $AgentHome + "`"") if (-not (Test-Path (Join-Path $AgentCurrent 'agent\http_app.py'))) { throw 'agent current missing http_app' } Write-Output '=== stop old 8910 only ===' Get-NetTCPConnection -LocalPort 8910 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } -# 停旧 worker(仅匹配 wwwroot agent) + Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object { - if ($_.CommandLine -and $_.CommandLine -like '*E:\wwwroot\agent*' -and $_.CommandLine -like '*worker_app*') { - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + $cl = $_.CommandLine + if ($null -ne $cl) { + if (($cl -like '*wwwroot*agent*') -and ($cl -like '*worker_app*')) { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } } } Start-Sleep -Seconds 2 Write-Output '=== write run scripts ===' -$runHttp = @" -@echo off -cd /d "$AgentCurrent" -set YTD_ENV=test -set AGENT_HTTP_PORT=8910 -:loop -"$AgentCurrent\.venv\Scripts\python.exe" -m agent.http_app >> "$Root\logs\agent-http.out.log" 2>> "$Root\logs\agent-http.err.log" -echo agent exited, restart in 3s >> "$Root\logs\agent-http.err.log" -timeout /t 3 /nobreak >nul -goto loop -"@ -Set-Content -Path (Join-Path $Root 'ops\run-agent-http.cmd') -Value $runHttp -Encoding ASCII +$runHttpPath = Join-Path $Root 'ops\run-agent-http.cmd' +$runWorkerPath = Join-Path $Root 'ops\run-agent-worker.cmd' +$logHttpOut = Join-Path $Root 'logs\agent-http.out.log' +$logHttpErr = Join-Path $Root 'logs\agent-http.err.log' +$logWorkerOut = Join-Path $Root 'logs\agent-worker.out.log' +$logWorkerErr = Join-Path $Root 'logs\agent-worker.err.log' -$runWorker = @" -@echo off -cd /d "$AgentCurrent" -set YTD_ENV=test -:loop -"$AgentCurrent\.venv\Scripts\python.exe" -m agent.worker_app >> "$Root\logs\agent-worker.out.log" 2>> "$Root\logs\agent-worker.err.log" -echo worker exited, restart in 3s >> "$Root\logs\agent-worker.err.log" -timeout /t 3 /nobreak >nul -goto loop -"@ -Set-Content -Path (Join-Path $Root 'ops\run-agent-worker.cmd') -Value $runWorker -Encoding ASCII +$runHttp = @( + '@echo off', + ('cd /d "' + $AgentCurrent + '"'), + 'set YTD_ENV=test', + 'set AGENT_HTTP_PORT=8910', + ':loop', + ('"' + $venvPy + '" -m agent.http_app >> "' + $logHttpOut + '" 2>> "' + $logHttpErr + '"'), + ('echo agent exited, restart in 3s >> "' + $logHttpErr + '"'), + 'timeout /t 3 /nobreak >nul', + 'goto loop' +) -join "`r`n" +Set-Content -Path $runHttpPath -Value $runHttp -Encoding ASCII + +$runWorker = @( + '@echo off', + ('cd /d "' + $AgentCurrent + '"'), + 'set YTD_ENV=test', + ':loop', + ('"' + $venvPy + '" -m agent.worker_app >> "' + $logWorkerOut + '" 2>> "' + $logWorkerErr + '"'), + ('echo worker exited, restart in 3s >> "' + $logWorkerErr + '"'), + 'timeout /t 3 /nobreak >nul', + 'goto loop' +) -join "`r`n" +Set-Content -Path $runWorkerPath -Value $runWorker -Encoding ASCII Write-Output '=== start http + worker via WMI ===' $w1 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ - CommandLine = "cmd.exe /c `"$(Join-Path $Root 'ops\run-agent-http.cmd')`"" + CommandLine = ('cmd.exe /c "' + $runHttpPath + '"') } -Write-Output "HTTP_WMI=$($w1.ReturnValue) pid=$($w1.ProcessId)" +Write-Output ("HTTP_WMI=" + $w1.ReturnValue + " pid=" + $w1.ProcessId) $w2 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ - CommandLine = "cmd.exe /c `"$(Join-Path $Root 'ops\run-agent-worker.cmd')`"" + CommandLine = ('cmd.exe /c "' + $runWorkerPath + '"') } -Write-Output "WORKER_WMI=$($w2.ReturnValue) pid=$($w2.ProcessId)" +Write-Output ("WORKER_WMI=" + $w2.ReturnValue + " pid=" + $w2.ProcessId) Start-Sleep -Seconds 8 try { $h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 8 - Write-Output "HEALTH=$($h.StatusCode) $($h.Content)" + Write-Output ("HEALTH=" + $h.StatusCode + " " + $h.Content) } catch { - Get-Content (Join-Path $Root 'logs\agent-http.err.log') -Tail 40 -ErrorAction SilentlyContinue - throw "agent health failed: $_" + if (Test-Path $logHttpErr) { Get-Content $logHttpErr -Tail 40 } + throw ('agent health failed: ' + $_) } $listenPid = 0 $listen = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 if ($listen) { $listenPid = $listen.OwningProcess } -$prevAdmin = $null -if (Test-Path (Join-Path $Root 'ops\active-release.json')) { - try { $prevAdmin = (Get-Content (Join-Path $Root 'ops\active-release.json') -Raw | ConvertFrom-Json).adminCurrent } catch {} +$prevAdmin = Join-Path $Root 'admin\current' +$ptrPath = Join-Path $Root 'ops\active-release.json' +if (Test-Path $ptrPath) { + try { + $old = Get-Content $ptrPath -Raw | ConvertFrom-Json + if ($old.adminCurrent) { $prevAdmin = $old.adminCurrent } + } catch {} } -@{ +$ptr = @{ root = $Root releaseId = $ReleaseId releaseRoot = $Release agentHome = $AgentHome agentCurrent = $AgentCurrent - adminCurrent = $(if ($prevAdmin) { $prevAdmin } else { Join-Path $Root 'admin\current' }) + adminCurrent = $prevAdmin pid = $listenPid updatedAt = (Get-Date).ToString('o') - note = 'safe deploy: no nginx change, no C:\yutongda touch' -} | ConvertTo-Json | Set-Content (Join-Path $Root 'ops\active-release.json') -Encoding UTF8 - + note = 'safe deploy: no nginx change' +} +$ptr | ConvertTo-Json | Set-Content $ptrPath -Encoding UTF8 Write-Output 'AGENT_SAFE_DEPLOY_OK' diff --git a/deploy/_remote_safe_start_ledger.ps1 b/deploy/_remote_safe_start_ledger.ps1 index 343eafd..a326845 100644 --- a/deploy/_remote_safe_start_ledger.ps1 +++ b/deploy/_remote_safe_start_ledger.ps1 @@ -1,96 +1,87 @@ -# 发布并启动新主账 8180(仅 E:\wwwroot\inquiry-new);禁止动现网 8080 / C:\yutongda -$ErrorActionPreference = 'Stop' -$Root = 'E:\wwwroot\inquiry-new' -$Jar = Join-Path $Root 'jeecg-system-start-3.5.3.jar' -$LogDir = Join-Path $Root 'logs' -$IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' -New-Item -ItemType Directory -Force -Path $Root, $LogDir | Out-Null +# Start ledger 8180 with config overlay; never touch prod 8080 / C:\yutongda +$ErrorActionPreference = "Stop" +$Root = "E:\wwwroot\inquiry-new" +$Jar = Join-Path $Root "jeecg-system-start-3.5.3.jar" +$LogDir = Join-Path $Root "logs" +$CfgDir = Join-Path $Root "config" +$IncomingJar = "C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar" +$IncomingCfg = "C:\Users\ytd_extdev_0903\incoming\ledger-config.zip" +New-Item -ItemType Directory -Force -Path $Root, $LogDir, $CfgDir | Out-Null -if (Test-Path $IncomingJar) { - Write-Output "=== replace jar from incoming ===" - Copy-Item $IncomingJar $Jar -Force +if (Test-Path $IncomingJar) { Copy-Item $IncomingJar $Jar -Force } +if (-not (Test-Path $Jar)) { throw "jar missing" } +if (Test-Path $IncomingCfg) { + Expand-Archive -Path $IncomingCfg -DestinationPath $CfgDir -Force +} +if (-not (Test-Path (Join-Path $CfgDir "application-inquiry.yml"))) { + @" +cas: + prefixUrl: http://127.0.0.1/cas-disabled +justauth: + enabled: true +"@ | Set-Content (Join-Path $CfgDir "application-inquiry.yml") -Encoding UTF8 } -if (-not (Test-Path $Jar)) { throw "jar missing: $Jar" } -$ji = Get-Item $Jar -Write-Output "jar bytes=$($ji.Length) mtime=$($ji.LastWriteTime)" -# 加载 E:\wwwroot\.env -Get-Content 'E:\wwwroot\.env' | ForEach-Object { +Get-Content "E:\wwwroot\.env" | ForEach-Object { $line = $_.Trim() - if (-not $line -or $line.StartsWith('#')) { return } - $i = $line.IndexOf('=') + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") if ($i -lt 1) { return } $k = $line.Substring(0, $i).Trim() - $v = $line.Substring($i + 1).Trim() - if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) } - [Environment]::SetEnvironmentVariable($k, $v, 'Process') - Set-Item -Path "Env:$k" -Value $v + $v = $line.Substring($i + 1).Trim().Trim('"') + Set-Item -Path ("Env:" + $k) -Value $v } - if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) { - if ($env:REDIS_URL -match 'redis://:([^@]+)@') { - $env:YTD_REDIS_PASSWORD = $Matches[1] - } elseif ($env:REDIS_URL -match 'redis://[^:]+:([^@]+)@') { - $env:YTD_REDIS_PASSWORD = $Matches[1] - } + if ($env:REDIS_URL -match "redis://:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } + elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } } -if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = '127.0.0.1' } -$env:YTD_REDIS_HOST = '127.0.0.1' -$env:LEDGER_PORT = '8180' +if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = "10.206.0.14" } +if (-not $env:YTD_REDIS_PORT) { $env:YTD_REDIS_PORT = "6379" } +if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" } +$env:LEDGER_PORT = "8180" +Write-Host ("MYSQL_DB=" + $env:MYSQL_DB + " MYSQL_HOST=" + $env:MYSQL_HOST + " REDIS_HOST=" + $env:YTD_REDIS_HOST + " REDIS_DB=" + $env:YTD_REDIS_DB) -Write-Host "MYSQL_DB=$env:MYSQL_DB MYSQL_HOST=$env:MYSQL_HOST LEDGER_PORT=$env:LEDGER_PORT" - -# 只停 inquiry-new 的 8180,绝不碰 yutongda 8080 Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { - if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') { - Write-Host "stop pid=$($_.ProcessId)" + if ($_.CommandLine -and $_.CommandLine -like "*inquiry-new*jeecg-system-start*") { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } -Get-NetTCPConnection -LocalPort 8180 -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty OwningProcess -Unique | - ForEach-Object { - $p = Get-CimInstance Win32_Process -Filter "ProcessId=$_" - if ($p -and $p.CommandLine -like '*inquiry-new*') { - Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue - } - } Start-Sleep -Seconds 2 -$Java = 'C:\yutongda\tools\jdk8\bin\java.exe' -if (-not (Test-Path $Java)) { throw "java missing $Java" } -$Out = Join-Path $LogDir 'stdout.log' -$Err = Join-Path $LogDir 'stderr.log' +$Java = "C:\yutongda\tools\jdk8\bin\java.exe" +$Out = Join-Path $LogDir "stdout.log" +$Err = Join-Path $LogDir "stderr.log" +# truncate logs +Set-Content $Out -Value "" -Encoding UTF8 +Set-Content $Err -Value "" -Encoding UTF8 $arg = @( - '-jar', $Jar, - '--spring.profiles.active=inquiry', - "--server.port=$env:LEDGER_PORT" + "-jar", $Jar, + "--spring.profiles.active=inquiry", + "--server.port=8180", + "--spring.config.additional-location=optional:file:./config/", + "--cas.prefixUrl=http://127.0.0.1/cas-disabled" ) -Write-Host "starting $Java $($arg -join ' ')" +Write-Host ("starting java jar inquiry profile redis=" + $env:YTD_REDIS_HOST + ":" + $env:YTD_REDIS_PORT + "/" + $env:YTD_REDIS_DB) $p = Start-Process -FilePath $Java -ArgumentList $arg -WorkingDirectory $Root -RedirectStandardOutput $Out -RedirectStandardError $Err -PassThru -WindowStyle Hidden -Write-Host "started pid=$($p.Id)" +Write-Host ("started pid=" + $p.Id) -# 等启动(最长约 90s) $ok = $false -for ($i = 1; $i -le 18; $i++) { +for ($i = 1; $i -le 24; $i++) { Start-Sleep -Seconds 5 try { - $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/' -UseBasicParsing -TimeoutSec 5 - Write-Host "health try=$i status=$($r.StatusCode)" + $r = Invoke-WebRequest -Uri "http://127.0.0.1:8180/jeecgboot/" -UseBasicParsing -TimeoutSec 5 + Write-Host ("health try=" + $i + " status=" + $r.StatusCode) $ok = $true break } catch { - Write-Host "health try=$i pending" + Write-Host ("health try=" + $i + " pending") } } if (-not $ok) { - Write-Host '--- stderr tail ---' - if (Test-Path $Err) { Get-Content $Err -Tail 50 } - throw 'ledger 8180 health failed' + if (Test-Path $Err) { Get-Content $Err -Tail 60 } + throw "ledger 8180 health failed" } - -# 确认现网 8080 仍在 $prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -if (-not $prod) { throw 'CRITICAL: prod 8080 down after start' } -Write-Output 'LEDGER_8180_OK' -Write-Output "PROD_8080_OK pid=$($prod.OwningProcess)" +if (-not $prod) { throw "CRITICAL: prod 8080 down" } +Write-Output "LEDGER_8180_OK" +Write-Output ("PROD_8080_OK pid=" + $prod.OwningProcess) diff --git a/deploy/_remote_sync_env_agent.ps1 b/deploy/_remote_sync_env_agent.ps1 new file mode 100644 index 0000000..7745cb2 --- /dev/null +++ b/deploy/_remote_sync_env_agent.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Continue' +Copy-Item 'E:\wwwroot\.env' 'E:\wwwroot\agent\current\.env' -Force -ErrorAction SilentlyContinue +Get-ChildItem 'E:\wwwroot\agent\releases' -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object { + $p1 = Join-Path $_.FullName 'agent\.env' + $p2 = Join-Path $_.FullName '.env' + if (Test-Path (Split-Path $p1)) { Copy-Item 'E:\wwwroot\.env' $p1 -Force -ErrorAction SilentlyContinue } + Copy-Item 'E:\wwwroot\.env' $p2 -Force -ErrorAction SilentlyContinue +} +Write-Output 'SYNC_OK' diff --git a/deploy/_remote_why_admin.ps1 b/deploy/_remote_why_admin.ps1 new file mode 100644 index 0000000..4caaa24 --- /dev/null +++ b/deploy/_remote_why_admin.ps1 @@ -0,0 +1,16 @@ +# 只读:公网后台到底是哪套静态 +$ErrorActionPreference = 'Continue' +Write-Output '=== admin current ===' +cmd /c 'dir E:\wwwroot\admin\current' +if (Test-Path 'E:\wwwroot\ops\active-release.json') { + Get-Content 'E:\wwwroot\ops\active-release.json' -Raw +} +Write-Output '=== index title sniff ===' +$idx = 'E:\wwwroot\admin\current\index.html' +if (Test-Path $idx) { + Select-String -Path $idx -Pattern 'title|询价|jeecg|vite' | Select-Object -First 8 | ForEach-Object { $_.Line.Trim() } +} +Write-Output '=== nginx jeecgboot / root ===' +Select-String -Path 'C:\nginx-1.18.0\myconf\ai.conf' -Pattern 'root |jeecgboot|proxy_pass|8180|8080' | + ForEach-Object { $_.Line.Trim() } +Write-Output 'ADMIN_WHY_DONE' diff --git a/deploy/_remote_wmi_start_ledger.ps1 b/deploy/_remote_wmi_start_ledger.ps1 new file mode 100644 index 0000000..20cacf3 --- /dev/null +++ b/deploy/_remote_wmi_start_ledger.ps1 @@ -0,0 +1,108 @@ +# 持久拉起主账:由 WMI 启动 cmd 循环,SSH 断开后仍存活。禁止动现网 8080。 +$ErrorActionPreference = 'Stop' +$Root = 'E:\wwwroot\inquiry-new' +$Www = 'E:\wwwroot' +$Jar = Join-Path $Root 'jeecg-system-start-3.5.3.jar' +$LogDir = Join-Path $Root 'logs' +$CfgDir = Join-Path $Root 'config' +$IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$IncomingCfg = 'C:\Users\ytd_extdev_0903\incoming\ledger-config.zip' +$Java = 'C:\yutongda\tools\jdk8\bin\java.exe' +New-Item -ItemType Directory -Force -Path $Root, $LogDir, $CfgDir, (Join-Path $Www 'ops') | Out-Null + +if (Test-Path $IncomingJar) { Copy-Item $IncomingJar $Jar -Force } +if (-not (Test-Path $Jar)) { throw 'jar missing' } +if (-not (Test-Path $Java)) { throw 'java missing' } +if (Test-Path $IncomingCfg) { + Expand-Archive -Path $IncomingCfg -DestinationPath $CfgDir -Force +} + +$inner = @' +$ErrorActionPreference = "Stop" +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') +} +if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) { + if ($env:REDIS_URL -match "redis://:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } + elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } +} +if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = "10.206.0.14" } +if (-not $env:YTD_REDIS_PORT) { $env:YTD_REDIS_PORT = "6379" } +if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" } +$env:LEDGER_PORT = "8180" +$Java = "C:\yutongda\tools\jdk8\bin\java.exe" +$Jar = "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" +Set-Location "E:\wwwroot\inquiry-new" +$arg = @( + "-jar", $Jar, + "--spring.profiles.active=inquiry", + "--server.port=8180", + "--spring.config.additional-location=optional:file:./config/", + "--cas.prefixUrl=http://127.0.0.1/cas-disabled", + "--jeecg.loginCaptchaEnabled=false" +) +if ($env:YTD_REDIS_USERNAME) { $arg += ("--spring.redis.username=" + $env:YTD_REDIS_USERNAME) } +& $Java @arg +'@ +$innerPath = Join-Path $Www 'ops\run-ledger-inner.ps1' +Set-Content -Path $innerPath -Value $inner -Encoding UTF8 + +$cmd = @" +@echo off +cd /d E:\wwwroot\inquiry-new +:loop +powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\wwwroot\ops\run-ledger-inner.ps1 >> E:\wwwroot\inquiry-new\logs\stdout.log 2>> E:\wwwroot\inquiry-new\logs\stderr.log +echo ledger exited, restart in 5s >> E:\wwwroot\inquiry-new\logs\stderr.log +timeout /t 5 /nobreak >nul +goto loop +"@ +$cmdPath = Join-Path $Www 'ops\run-ledger.cmd' +Set-Content -Path $cmdPath -Value $cmd -Encoding ASCII + +# 只停 inquiry-new 的 java +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and $_.CommandLine -like '*run-ledger.cmd*') { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 2 + +$w = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ + CommandLine = ('cmd.exe /c "' + $cmdPath + '"') +} +Write-Output ("LEDGER_WMI=" + $w.ReturnValue + " pid=" + $w.ProcessId) + +$ok = $false +for ($i = 1; $i -le 30; $i++) { + Start-Sleep -Seconds 5 + $listen = Get-NetTCPConnection -LocalPort 8180 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $listen) { Write-Output ("try=" + $i + " not listen"); continue } + try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/wmi' -UseBasicParsing -TimeoutSec 5 + Write-Output ("try=" + $i + " listen pid=" + $listen.OwningProcess + " http=" + $r.StatusCode) + $ok = $true + break + } catch { + $code = $null + try { $code = $_.Exception.Response.StatusCode.value__ } catch {} + Write-Output ("try=" + $i + " listen pid=" + $listen.OwningProcess + " http_code=" + $code) + if ($code) { $ok = $true; break } + } +} +if (-not $ok) { + Get-Content (Join-Path $LogDir 'stdout.log') -Tail 40 -ErrorAction SilentlyContinue + throw 'ledger 8180 failed to stay up' +} +$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $prod) { throw 'CRITICAL: prod 8080 down' } +Write-Output 'LEDGER_WMI_OK' +Write-Output ("PROD_8080_OK pid=" + $prod.OwningProcess) diff --git a/deploy/ai.conf.cutover-8180 b/deploy/ai.conf.cutover-8180 new file mode 100644 index 0000000..1d930b6 --- /dev/null +++ b/deploy/ai.conf.cutover-8180 @@ -0,0 +1,74 @@ +# ai.ytd-scm.com cutover: Jeecg admin static + ledger 8180 + agent 8910 +# /jeecgboot -> 新主账 8180(context-path /jeecgboot) +# /jeecg-boot -> 仍指现网 8080(旧路径兼容,正式前端不用) +# wecom / inquiry-form -> 8910 +server { + listen 80; + server_name ai.ytd-scm.com; + return 301 https://$host$request_uri; +} +server { + listen 443 ssl; + server_name ai.ytd-scm.com; + ssl_certificate C:/nginx-1.18.0/cert/ai.ytd-scm.com.crt; + ssl_certificate_key C:/nginx-1.18.0/cert/ai.ytd-scm.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + root E:/wwwroot/admin/current; + index index.html; + location = / { add_header Cache-Control "no-store" always; try_files /index.html =404; } + location = /index.html { add_header Cache-Control "no-store" always; } + location / { try_files $uri $uri/ /index.html; } + location /jeecg-boot/ { + proxy_pass http://127.0.0.1:8080/jeecg-boot/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 120s; + } + location /jeecgboot/ { + proxy_pass http://127.0.0.1:8180/jeecgboot/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 120s; + } + location = /wecom/callback { + proxy_pass http://127.0.0.1:8910/wecom/callback; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + client_max_body_size 20m; + } + location = /wecom/robot/callback { + proxy_pass http://127.0.0.1:8812/wecom/robot/callback; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + client_max_body_size 100m; + } + location ^~ /inquiry-form/upload/ { + proxy_pass http://127.0.0.1:8910/inquiry-form/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + add_header Cache-Control "no-store" always; + client_max_body_size 21m; + } + location ^~ /inquiry-form/ { + proxy_pass http://127.0.0.1:8910/inquiry-form/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + add_header Cache-Control "no-store" always; + client_max_body_size 256k; + } + location = /wecom/callback/health { proxy_pass http://127.0.0.1:8910/health; } + location = /agent/health { proxy_pass http://127.0.0.1:8910/health; } + location = /ledger/health { proxy_pass http://127.0.0.1:8180/jeecgboot/; } +} diff --git a/deploy/config/application-inquiry.yml b/deploy/config/application-inquiry.yml new file mode 100644 index 0000000..f16b262 --- /dev/null +++ b/deploy/config/application-inquiry.yml @@ -0,0 +1,60 @@ +# 覆盖配置:明确关闭登录图形验证码(与无验证码登录页对齐) +cas: + prefixUrl: http://127.0.0.1/cas-disabled + +spring: + redis: + host: ${YTD_REDIS_HOST:10.206.0.14} + port: ${YTD_REDIS_PORT:6379} + database: ${YTD_REDIS_DB:1} + username: ${YTD_REDIS_USERNAME:} + password: ${YTD_REDIS_PASSWORD:} + +jeecg: + loginCaptchaEnabled: false + swagger: + title: 友通达询价系统 API + description: 询价主账与后台关联接口 + base-packages: org.jeecg.modules.inquiry + extra-controller-names: LoginController,SysUserController,SysRoleController,SysPermissionController,SysDictController,SysDictItemController,CommonController + file-view-domain: http://127.0.0.1 + elasticsearch: + cluster-name: jeecg-ES + cluster-nodes: 127.0.0.1:9200 + check-enabled: false + redisson: + enabled: false + xxljob: + enabled: false + +knife4j: + enable: true + production: false + +justauth: + enabled: true + type: + GITHUB: + client-id: local + client-secret: local + redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/github/callback + WECHAT_ENTERPRISE: + client-id: local + client-secret: local + redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/wechat_enterprise/callback + agent-id: "0" + DINGTALK: + client-id: local + client-secret: local + redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/dingtalk/callback + WECHAT_OPEN: + client-id: local + client-secret: local + redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/wechat_open/callback + cache: + type: default + prefix: 'demo::' + timeout: 1h + +third-app: + enabled: false diff --git a/deploy/nssm/SERVICES.md b/deploy/nssm/SERVICES.md new file mode 100644 index 0000000..c6fc5bc --- /dev/null +++ b/deploy/nssm/SERVICES.md @@ -0,0 +1,24 @@ +# NSSM / Windows 服务单元清单(框架壳) + +新系统只注册下列自研服务(另加 Nginx/LibreOffice/库,不计入本表)。 +**禁止**五个 Worker、四个 uvicorn、Redis `wecom:worker:singleton`。 + +并存期端口:主账 8180、智能体 HTTP 8910、AIBOT 8913;勿占现网 8080/8810/8813。 + +| 服务名(建议) | 进程 | 工作目录(示例) | 启动命令(示例) | 备注 | +|---|---|---|---|---| +| `YtdInquiryLedger` | 主账 Jeecg | `E:\wwwroot\inquiry-new` | `java -jar …`(以发布脚本为准) | 端口 8180;库 `inquiry_robot` | +| `YtdInquiryAgentHttp` | 智能体 HTTP | `E:\wwwroot\agent\current` | `python -m agent.http_app` | 端口 8910;只验签入队 | +| `YtdInquiryAgentWorker` | 智能体 Worker | 同上 | `python -m agent.worker_app` | 无对外端口;LibreOffice 1 槽 | +| `YtdInquiryAibotBridge` | AIBOT 桥 | `E:\wwwroot\aibot-bridge` | `node server.cjs` | 绑定 127.0.0.1:8913;默认不连 Bot | +| `YtdInquiryAdmin` | 可选静态托管 | `E:\wwwroot\admin\current` | 通常由 Nginx 托管,可不注册 NSSM | 经 `/jeecgboot` 打 8180 | + +## 环境 + +- 活动配置真相:`E:\wwwroot\.env`(勿改 `C:\yutongda` 活动 `.env`) +- `YTD_ENV=test|prod`;测试告警正文加 `[test]` +- AIBOT:`WECOM_AIBOT_ENABLED=false` 直至切流 + +## 本文件范围 + +只列服务名与进程边界;不含切流 Nginx、不含业务开关。具体安装命令见 `deploy/` 下远程脚本。 diff --git a/inquiry-agent/README.md b/inquiry-agent/README.md index ecd0044..d85d80b 100644 --- a/inquiry-agent/README.md +++ b/inquiry-agent/README.md @@ -1,8 +1,45 @@ # 智能体 agent -Python 3.12 + FastAPI。HTTP 默认 `8910`。 +Python 3.12 + FastAPI。HTTP 默认 `8910`;Worker 另起进程。 + +## 基础框架自检 + +```text +set REDIS_BACKEND=memory +set CHECKPOINT_BACKEND=memory +python -m agent.foundation_selftest +python -m agent.runtime_db.migrate --list +``` + +PG 真迁移:配好 `PG_*` 后 `python -m agent.runtime_db.migrate`(禁止 `ytd_runtime`)。 + +消息账本:`MESSAGE_STORE_BACKEND=auto|memory|postgres`(有 DSN 时优先 PG inbox/outbox)。 + +## Redis 骨架 + +- 包:`agent/redis_coord/`(连接+前缀、Stream 入队/认领、短幂等、限流、token 缓存)。 +- **db1 不是名字**:Redis 只有数字逻辑库(URL `…/1`),与现网 `…/0` 隔离;可读名是键前缀 **`inquiry_robot:`**。 +- 自检:`set REDIS_BACKEND=memory` 后 `python -m agent.redis_coord.selftest`。 +- 正式:`REDIS_URL=redis://…@host:6379/1` + `REDIS_KEY_PREFIX=inquiry_robot:`(禁止 `ytd:prod:`)。 + +## LangGraph 骨架 + +- 包:`agent/graph/`(状态、分文件空节点、interrupt 停点、PG/Memory checkpoint、start/resume/wake)。 +- **无询价主逻辑**;节点只打日志。HTTP 回调禁止 `invoke` 整图。 +- 自检:`set CHECKPOINT_BACKEND=memory` 后执行 `python -m agent.graph.selftest`。 +- 正式:`CHECKPOINT_BACKEND=postgres` + `PG_*` 指向 `inquiry_robot_runtime`(禁止 `ytd_runtime`)。 + +## 本迭代骨架(路由 / PDF / 识别) + +- DeepSeek A:`agent/routing/ordered_actions.py` + `deepseek_a.py`(strict `route_decision` tool;默认 stub,不开费)。 +- 分发:`agent/routing/dispatch.py`;通道 inbox → route → handler 表。 +- 真网开关:`LLM_DATA_USAGE_CONFIRMED=true` 且 `LLM_ALLOW_NETWORK=true`(Worker)才可能打模型。 +- LibreOffice:`agent/jobs/libreoffice.py` + `stream:pdf`(仅 1 槽);路径 `LIBREOFFICE_PATH`。 +- 识别 / 存档:`stream:recognition` / `stream:archive` 入队壳。 +- 字段合同:仓库 `contracts/*.json`;拉取备份在 `_ref/prod-pull/`(gitignore)。 +- 本机密钥:仓库根 `.env`(gitignore);新库口令来自服务器 `E:\wwwroot\ops\inquiry_robot_db_secrets.json`。 +- 另:session/suspend/job/死信、LLM 各 mode 空模块、S3 壳、ledger API 签名壳、模板 registry、policy、AIBOT 长连接壳、Java TMS 空包、`deploy/nssm/SERVICES.md`。 -## 本迭代:企微文字 echo 回复 链路:回调/注入 → inbox 入队(快回)→ echo handler → outbox → 企微 `message/send`。 diff --git a/inquiry-agent/agent/channel/aibot/__init__.py b/inquiry-agent/agent/channel/aibot/__init__.py new file mode 100644 index 0000000..af7f8dd --- /dev/null +++ b/inquiry-agent/agent/channel/aibot/__init__.py @@ -0,0 +1,94 @@ +""" +AIBOT 入站入口:桥进程把长连接消息转到本路由。 + +本文件职责:校验 bridge token、归一 InboundMessage、入 inbox 快回。 +禁止:在本请求线程调 LLM / Graph / 出站企微。 +路径:/internal/aibot/inbound(对齐 prompt/13)。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel, Field + +from agent.channel.queue import get_message_store +from agent.channel.wecom.models import InboundMessage +from agent.config import get_settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/internal/aibot", tags=["aibot"]) + + +class AibotInboundBody(BaseModel): + """桥转发的归一字段(桥侧已解析,智能体不再调企微换 ID)。""" + + sender_id: str = Field(description="企微 userid,必须来自 AIBOT 报文") + message_id: str + content: str = "" + msg_type: str = "text" + chat_id: str = "" + chat_type: str = "group" + agent_id: str = "" + create_time: int = 0 + media: dict[str, Any] = Field(default_factory=dict) + raw: dict[str, Any] = Field(default_factory=dict) + + +def _check_bridge_token(x_token: Optional[str]) -> None: + s = get_settings() + expected = (getattr(s, "wecom_aibot_bridge_token", None) or "").strip() + if not expected: + if (s.ytd_env or "").lower() == "prod": + raise HTTPException(status_code=503, detail="AIBOT bridge token 未配置") + logger.warning("aibot inbound:未配置 bridge token,test 跳过校验") + return + if (x_token or "").strip() != expected: + raise HTTPException(status_code=401, detail="unauthorized") + + +@router.get("/ping") +def aibot_ping() -> dict[str, str]: + return {"status": "ok", "channel": "aibot"} + + +@router.post("/inbound") +def aibot_inbound( + body: AibotInboundBody, + x_aibot_bridge_token: Optional[str] = Header( + default=None, alias="X-Aibot-Bridge-Token" + ), +) -> dict[str, Any]: + """ + 接收桥转发入站:无 sender_id 拒绝;其余入 inbox。 + """ + _check_bridge_token(x_aibot_bridge_token) + msg = InboundMessage( + sender_id=body.sender_id.strip(), + message_id=body.message_id.strip(), + content=body.content or "", + msg_type=body.msg_type or "text", + chat_id=body.chat_id or "", + chat_type=body.chat_type or "group", + agent_id=body.agent_id or "", + create_time=int(body.create_time or 0), + media=body.media or {}, + raw=body.raw or {}, + ) + if not msg.has_sender(): + raise HTTPException(status_code=400, detail="missing_sender_id") + if not msg.message_id: + raise HTTPException(status_code=400, detail="missing_message_id") + + ok, info = get_message_store().enqueue_inbound(msg) + logger.info( + "aibot.inbound ok=%s info=%s sender=%s msg_id=%s", + ok, + info, + msg.sender_id, + msg.message_id, + ) + return {"status": "accepted" if ok else "duplicate", "detail": info} diff --git a/inquiry-agent/agent/channel/bridge/__init__.py b/inquiry-agent/agent/channel/bridge/__init__.py index 3941c80..cc0bd00 100644 --- a/inquiry-agent/agent/channel/bridge/__init__.py +++ b/inquiry-agent/agent/channel/bridge/__init__.py @@ -1,18 +1,102 @@ """ 主账唤醒桥接路由(合并原 bridge_api)。 -本文件职责:接收主账鉴权后的 wake 事件,入队由 Worker 结构化 resume。 +本文件职责:校验 callback token,接收结构化 wake,写入 Redis Stream 后快回。 禁止:在本请求线程跑完整 Graph;禁止本包直连 MySQL / TMS。 +Worker:jobs.wake_consumer 认领后 GraphRuntime.wake_from_worker。 """ from __future__ import annotations -from fastapi import APIRouter +import logging +from typing import Any, Optional + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel, Field + +from agent.config import get_settings +from agent.graph.wake import WorkerWakePayload +from agent.jobs.wake_consumer import enqueue_wake +from agent.redis_coord import get_redis_runtime + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/internal/bridge", tags=["bridge"]) +class BridgeWakeBody(BaseModel): + """主账 → 智能体结构化唤醒。""" + + thread_id: str + inquiry_no: str + quote_version: int = 0 + wait_version: int = 0 + kind: str = Field(description="如 recognition_done / pdf_done / ledger_transition") + detail: dict[str, Any] = Field(default_factory=dict) + + +def _expected_token() -> str: + s = get_settings() + return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip() + + +def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None: + expected = _expected_token() + if not expected: + if (get_settings().ytd_env or "").lower() == "prod": + raise HTTPException(status_code=503, detail="callback token 未配置") + logger.warning("bridge:未配置 callback token,test 允许空校验(仅骨架)") + return + provided = (x_agent_token or "").strip() + if not provided and authorization: + auth = authorization.strip() + if auth.lower().startswith("bearer "): + provided = auth[7:].strip() + else: + provided = auth + if provided != expected: + raise HTTPException(status_code=401, detail="unauthorized") + + @router.get("/ping") def bridge_ping() -> dict[str, str]: - """主账唤醒路径占位;正式实现需校验 AGENT_CALLBACK_TOKEN。""" - return {"status": "ok", "channel": "bridge", "note": "scaffold-only"} + return {"status": "ok", "channel": "bridge"} + + +@router.post("/wake") +def bridge_wake( + body: BridgeWakeBody, + authorization: Optional[str] = Header(default=None), + x_agent_callback_token: Optional[str] = Header( + default=None, alias="X-Agent-Callback-Token" + ), +) -> dict[str, Any]: + """ + 接收结构化 wake:校验后写入 Redis Stream,立即返回。 + + 不在此 invoke Graph。 + """ + _check_token(authorization, x_agent_callback_token) + try: + payload = WorkerWakePayload( + thread_id=body.thread_id, + inquiry_no=body.inquiry_no, + quote_version=body.quote_version, + wait_version=body.wait_version, + kind=body.kind, + detail=body.detail, + ) + entry_id = enqueue_wake(get_redis_runtime().client, payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + logger.exception("bridge.wake 入队失败") + raise HTTPException(status_code=503, detail=f"enqueue_failed:{exc}") from exc + + logger.info( + "bridge.wake 已入队 Redis thread=%s kind=%s id=%s", + body.thread_id, + body.kind, + entry_id, + ) + return {"status": "accepted", "entry_id": entry_id} diff --git a/inquiry-agent/agent/channel/clarification.py b/inquiry-agent/agent/channel/clarification.py new file mode 100644 index 0000000..c07cd62 --- /dev/null +++ b/inquiry-agent/agent/channel/clarification.py @@ -0,0 +1,199 @@ +""" +clarification / H5 token 绑定(骨架)。 + +本文件职责:签发与校验一次性 token;优先写 PG clarification_binding,无库则内存。 +禁止:URL 携带 userid;身份以绑定时的 sender_id 为准。 +""" + +from __future__ import annotations + +import logging +import secrets +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Optional + +from agent.config import get_settings +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + +# H5 TTL 30 分钟(prompt/14) +DEFAULT_TTL_SECONDS = 30 * 60 + + +@dataclass +class ClarificationRecord: + token: str + thread_id: str + wait_version: int + sender_id: str + allowed_actions: list[str] + expires_at: float + consumed: bool = False + + +_MEM: dict[str, ClarificationRecord] = {} +_MEM_LOCK = threading.Lock() + + +def issue_token( + *, + thread_id: str, + wait_version: int, + sender_id: str, + allowed_actions: list[str], + ttl_seconds: int = DEFAULT_TTL_SECONDS, +) -> str: + """ + 签发 clarification token。 + + 副作用:写 PG 或内存;返回随机 token(不进 URL 的 userid)。 + """ + token = secrets.token_urlsafe(24) + expires = time.time() + ttl_seconds + rec = ClarificationRecord( + token=token, + thread_id=thread_id, + wait_version=wait_version, + sender_id=sender_id, + allowed_actions=list(allowed_actions), + expires_at=expires, + ) + if _try_pg_insert(rec, ttl_seconds): + return token + with _MEM_LOCK: + _MEM[token] = rec + logger.info("clarification 内存签发 thread=%s wait=%s", thread_id, wait_version) + return token + + +def validate_token( + token: str, + *, + action: str = "", + consume: bool = False, +) -> Optional[ClarificationRecord]: + """ + 校验 token:存在、未过期、未消费;若指定 action 须在允许集合。 + + consume=True:一次性消费。 + """ + if not (token or "").strip(): + return None + rec = _try_pg_load(token) or _mem_load(token) + if rec is None: + return None + if rec.consumed or rec.expires_at < time.time(): + return None + if action and action not in rec.allowed_actions: + return None + if consume: + _consume(token) + rec.consumed = True + return rec + + +def _mem_load(token: str) -> Optional[ClarificationRecord]: + with _MEM_LOCK: + return _MEM.get(token) + + +def _consume(token: str) -> None: + if _try_pg_consume(token): + return + with _MEM_LOCK: + rec = _MEM.get(token) + if rec: + rec.consumed = True + + +def _try_pg_insert(rec: ClarificationRecord, ttl_seconds: int) -> bool: + settings = get_settings() + dsn = settings.resolve_pg_dsn() + if not dsn: + return False + try: + assert_safe_database(dsn) + expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) + import json + + with connect_psycopg(dsn) as conn: + conn.execute( + """ + INSERT INTO clarification_binding ( + token, thread_id, wait_version, sender_id, allowed_actions, expires_at + ) VALUES (%s, %s, %s, %s, %s::jsonb, %s) + """, + ( + rec.token, + rec.thread_id, + rec.wait_version, + rec.sender_id, + json.dumps(rec.allowed_actions, ensure_ascii=False), + expires, + ), + ) + conn.commit() + return True + except Exception as exc: # noqa: BLE001 + logger.warning("clarification PG 写入失败,改用内存:%s", exc) + return False + + +def _try_pg_load(token: str) -> Optional[ClarificationRecord]: + settings = get_settings() + dsn = settings.resolve_pg_dsn() + if not dsn: + return None + try: + with connect_psycopg(dsn) as conn: + row = conn.execute( + """ + SELECT thread_id, wait_version, sender_id, allowed_actions, + EXTRACT(EPOCH FROM expires_at), consumed_at + FROM clarification_binding WHERE token = %s + """, + (token,), + ).fetchone() + if not row: + return None + actions = row[3] + if isinstance(actions, str): + import json + + actions = json.loads(actions) + return ClarificationRecord( + token=token, + thread_id=str(row[0]), + wait_version=int(row[1]), + sender_id=str(row[2]), + allowed_actions=list(actions or []), + expires_at=float(row[4] or 0), + consumed=row[5] is not None, + ) + except Exception: # noqa: BLE001 + return None + + +def _try_pg_consume(token: str) -> bool: + settings = get_settings() + dsn = settings.resolve_pg_dsn() + if not dsn: + return False + try: + with connect_psycopg(dsn) as conn: + conn.execute( + """ + UPDATE clarification_binding + SET consumed_at = NOW() + WHERE token = %s AND consumed_at IS NULL + """, + (token,), + ) + conn.commit() + return True + except Exception: # noqa: BLE001 + return False diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index 3b3243a..266773d 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -1,22 +1,79 @@ """ H5 补问 / 附件上传通道。 -本文件职责:挂载 /inquiry-form 相关壳路由;字段快照与流程状态在 Graph/PG,不在本包。 -禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS。 +本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。 +禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。 """ from __future__ import annotations -from fastapi import APIRouter +import logging +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from agent.channel.clarification import validate_token +from agent.handlers.card_action import handle_card_action + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/inquiry-form", tags=["h5"]) +class H5SubmitBody(BaseModel): + """H5 提交体:业务字段放 fields;身份只认服务端 token 绑定。""" + + action: str = Field(default="submit_clarify") + fields: dict[str, Any] = Field(default_factory=dict) + + @router.get("/ping") def h5_ping() -> dict[str, str]: - """ - H5 路由占位探测。 + """存活探测。""" + return {"status": "ok", "channel": "h5"} - 正式页用一次性 token;骨架不校验 token、不落库。 + +@router.get("/{token}") +def h5_open(token: str) -> dict[str, Any]: """ - return {"status": "ok", "channel": "h5", "note": "scaffold-only"} + 打开补问页:校验 token 未过期。 + + 返回允许动作与 wait_version;不返回内部异常栈。 + """ + rec = validate_token(token) + if rec is None: + raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开") + return { + "status": "ok", + "thread_id": rec.thread_id, + "wait_version": rec.wait_version, + "allowed_actions": rec.allowed_actions, + "note": "骨架:字段列表后续按合同生成", + } + + +@router.post("/{token}/submit") +def h5_submit(token: str, body: H5SubmitBody) -> dict[str, Any]: + """ + 提交补问:消费 token,转入 card_action 骨架(不跑整图)。 + + 副作用:token 一次性;入队/Handler 后续接 Worker。 + """ + rec = validate_token(token, action=body.action or "submit_clarify", consume=True) + if rec is None: + raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开") + handle_card_action( + sender_id=rec.sender_id, + thread_id=rec.thread_id, + wait_version=rec.wait_version, + action=body.action or "submit_clarify", + payload=body.fields, + ) + logger.info( + "h5.submit ok thread=%s wait=%s action=%s", + rec.thread_id, + rec.wait_version, + body.action, + ) + return {"status": "accepted", "thread_id": rec.thread_id} diff --git a/inquiry-agent/agent/channel/pg_store.py b/inquiry-agent/agent/channel/pg_store.py new file mode 100644 index 0000000..7369e50 --- /dev/null +++ b/inquiry-agent/agent/channel/pg_store.py @@ -0,0 +1,295 @@ +""" +PostgreSQL inbox / outbox 账本。 + +本文件职责:落 agent_inbox / agent_outbox;按条认领 + 超时回收。 +调用:需已执行 migrations/001_runtime_core_v1.sql。 +禁止:在回调线程跑长事务;单次操作短 SQL。 +线程:每个方法短连接或共用连接需自行串行;本实现每操作用独立短连接,避免跨线程共享 cursor。 +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from agent.channel.queue import InboxItem, OutboxItem, TaskState +from agent.channel.wecom.models import InboundMessage +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class PostgresMessageStore: + """ + PG 消息账本。 + + claim_timeout_sec:认领超时后可被其他消费者回收。 + claimed_by:本进程标识,便于排查。 + """ + + def __init__( + self, + dsn: str, + *, + claim_timeout_sec: float = 120.0, + worker_id: str = "", + ) -> None: + assert_safe_database(dsn) + self._dsn = dsn + self._claim_timeout = claim_timeout_sec + self._worker_id = worker_id or f"pid-{threading.get_ident()}" + self._inbox_event = threading.Event() + self._outbox_event = threading.Event() + + def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]: + """写入 agent_inbox;message_id 唯一冲突视为重复。""" + if not message.has_sender(): + return False, "missing_sender_id" + item_id = str(uuid.uuid4()) + payload = json.dumps(message.to_dict(), ensure_ascii=False) + try: + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO agent_inbox ( + id, message_id, sender_id, chat_id, chat_type, payload, state + ) VALUES (%s, %s, %s, %s, %s, %s::jsonb, 'pending') + """, + ( + item_id, + message.message_id, + message.sender_id, + message.chat_id, + message.chat_type, + payload, + ), + ) + conn.commit() + self._inbox_event.set() + logger.info( + "pg inbox 入队 id=%s msg_id=%s sender=%s", + item_id, + message.message_id, + message.sender_id, + ) + return True, item_id + except Exception as exc: # noqa: BLE001 + # unique_violation + if "unique" in str(exc).lower() or "duplicate" in str(exc).lower(): + return False, "duplicate_message_id" + logger.exception("pg inbox 入队失败") + raise + + def claim_inbound(self) -> Optional[InboxItem]: + """认领一条 pending 或超时 claimed。""" + timeout = int(self._claim_timeout) + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + """ + WITH cte AS ( + SELECT id FROM agent_inbox + WHERE state = 'pending' + OR ( + state = 'claimed' + AND claimed_at IS NOT NULL + AND claimed_at < NOW() - (%s || ' seconds')::interval + ) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE agent_inbox i + SET state = 'claimed', + claimed_at = NOW(), + claimed_by = %s, + attempts = attempts + 1, + updated_at = NOW() + FROM cte + WHERE i.id = cte.id + RETURNING i.id, i.payload, i.attempts, i.last_error, i.claimed_at + """, + (str(timeout), self._worker_id), + ).fetchone() + conn.commit() + if not row: + self._inbox_event.clear() + return None + payload = row[1] + if isinstance(payload, str): + payload = json.loads(payload) + msg = InboundMessage.from_dict(dict(payload)) + claimed_at = row[4] + ts = claimed_at.timestamp() if hasattr(claimed_at, "timestamp") else time.time() + return InboxItem( + id=str(row[0]), + message=msg, + state=TaskState.CLAIMED, + claimed_at=ts, + attempts=int(row[2] or 0), + last_error=str(row[3] or ""), + ) + + def complete_inbound(self, item_id: str, *, error: str = "") -> None: + state = "failed" if error else "done" + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + UPDATE agent_inbox + SET state = %s, last_error = %s, updated_at = NOW() + WHERE id = %s + """, + (state, error or "", item_id), + ) + conn.commit() + + def enqueue_outbound( + self, + *, + touser: str, + content: str, + dedupe_key: str = "", + ) -> tuple[bool, str]: + if not touser or not content: + return False, "invalid_outbox" + item_id = str(uuid.uuid4()) + try: + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO agent_outbox ( + id, dedupe_key, touser, content, payload, state + ) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending') + """, + (item_id, dedupe_key or "", touser, content), + ) + conn.commit() + self._outbox_event.set() + logger.info("pg outbox 入队 id=%s touser=%s", item_id, touser) + return True, item_id + except Exception as exc: # noqa: BLE001 + if dedupe_key and ( + "unique" in str(exc).lower() or "duplicate" in str(exc).lower() + ): + return False, "duplicate_outbox" + logger.exception("pg outbox 入队失败") + raise + + def claim_outbound(self) -> Optional[OutboxItem]: + timeout = int(self._claim_timeout) + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + """ + WITH cte AS ( + SELECT id FROM agent_outbox + WHERE state = 'pending' + OR ( + state = 'claimed' + AND claimed_at IS NOT NULL + AND claimed_at < NOW() - (%s || ' seconds')::interval + ) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE agent_outbox o + SET state = 'claimed', + claimed_at = NOW(), + claimed_by = %s, + attempts = attempts + 1, + updated_at = NOW() + FROM cte + WHERE o.id = cte.id + RETURNING o.id, o.touser, o.content, o.attempts, o.last_error, + o.created_at, o.dedupe_key, o.claimed_at + """, + (str(timeout), self._worker_id), + ).fetchone() + conn.commit() + if not row: + self._outbox_event.clear() + return None + created = row[5] + created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time() + claimed = row[7] + claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time() + return OutboxItem( + id=str(row[0]), + touser=str(row[1]), + content=str(row[2]), + state=TaskState.CLAIMED, + claimed_at=claimed_ts, + attempts=int(row[3] or 0), + last_error=str(row[4] or ""), + created_at=created_ts, + dedupe_key=str(row[6] or ""), + ) + + def complete_outbound( + self, item_id: str, *, error: str = "", permanent: bool = False + ) -> None: + with connect_psycopg(self._dsn) as conn: + if error: + row = conn.execute( + "SELECT attempts FROM agent_outbox WHERE id = %s", + (item_id,), + ).fetchone() + attempts = int(row[0]) if row else 0 + if permanent or attempts >= 5: + conn.execute( + """ + UPDATE agent_outbox + SET state = 'failed', last_error = %s, updated_at = NOW() + WHERE id = %s + """, + (error, item_id), + ) + else: + conn.execute( + """ + UPDATE agent_outbox + SET state = 'pending', claimed_at = NULL, claimed_by = '', + last_error = %s, updated_at = NOW() + WHERE id = %s + """, + (error, item_id), + ) + self._outbox_event.set() + else: + conn.execute( + """ + UPDATE agent_outbox + SET state = 'done', last_error = '', updated_at = NOW() + WHERE id = %s + """, + (item_id,), + ) + conn.commit() + + def wait_inbox(self, timeout: float = 1.0) -> None: + self._inbox_event.wait(timeout=timeout) + + def wait_outbox(self, timeout: float = 1.0) -> None: + self._outbox_event.wait(timeout=timeout) + + def stats(self) -> dict[str, Any]: + with connect_psycopg(self._dsn) as conn: + inbox = conn.execute( + "SELECT COUNT(*) FROM agent_inbox WHERE state IN ('pending','claimed')" + ).fetchone() + outbox = conn.execute( + "SELECT COUNT(*) FROM agent_outbox WHERE state IN ('pending','claimed')" + ).fetchone() + return { + "backend": "postgres", + "inbox": int(inbox[0] if inbox else 0), + "outbox": int(outbox[0] if outbox else 0), + } diff --git a/inquiry-agent/agent/channel/queue.py b/inquiry-agent/agent/channel/queue.py index 110350c..d535e12 100644 --- a/inquiry-agent/agent/channel/queue.py +++ b/inquiry-agent/agent/channel/queue.py @@ -1,9 +1,9 @@ """ -进程内 inbox / outbox 队列(骨架过渡实现)。 +进程内 / PostgreSQL inbox·outbox 账本。 -本文件职责:回调快写入队、按条认领、message_id 去重;后续可替换为 PostgreSQL 表。 +本文件职责:定义任务结构、Memory 实现、按配置选择后端工厂。 禁止:Redis 全局 Worker 单例锁;禁止在回调线程里等 LLM。 -为何先用内存:先打通「收消息→回复」联调;落 PG 迁移不挡本迭代验收。 +有 PG_DSN 且 MESSAGE_STORE_BACKEND=postgres|auto 时用表;否则内存(本机联调)。 """ from __future__ import annotations @@ -15,7 +15,7 @@ import uuid from collections import OrderedDict from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union from agent.channel.wecom.models import InboundMessage @@ -207,6 +207,7 @@ class MemoryMessageStore: def stats(self) -> dict[str, Any]: with self._lock: return { + "backend": "memory", "inbox": len(self._inbox), "outbox": len(self._outbox), "seen_msg": len(self._seen_msg), @@ -218,15 +219,55 @@ class MemoryMessageStore: store.popitem(last=False) -# 进程内单例:HTTP 回调与消费线程共享(同进程)。多进程部署时必须换 PG。 -_STORE: Optional[MemoryMessageStore] = None +MessageStoreImpl = Union[MemoryMessageStore, Any] + +_STORE: Optional[MessageStoreImpl] = None _STORE_LOCK = threading.Lock() -def get_message_store() -> MemoryMessageStore: - """获取进程内消息账本单例。""" +def get_message_store() -> MessageStoreImpl: + """ + 获取消息账本单例。 + + MESSAGE_STORE_BACKEND=postgres 且 PG 可用 → PostgresMessageStore; + auto 有 DSN 则尝试 PG,失败回退内存;memory 强制内存。 + """ global _STORE with _STORE_LOCK: if _STORE is None: - _STORE = MemoryMessageStore() + _STORE = _create_store() return _STORE + + +def reset_message_store_for_tests() -> None: + """仅自检用:清空单例。""" + global _STORE + with _STORE_LOCK: + _STORE = None + + +def _create_store() -> MessageStoreImpl: + from agent.config import get_settings + + settings = get_settings() + backend = (getattr(settings, "message_store_backend", None) or "auto").strip().lower() + dsn = settings.resolve_pg_dsn() + + use_pg = backend == "postgres" or (backend == "auto" and bool(dsn)) + if use_pg and dsn: + try: + from agent.channel.pg_store import PostgresMessageStore + from agent.runtime_db import assert_safe_database + + assert_safe_database(dsn) + store = PostgresMessageStore(dsn) + store.stats() + logger.info("消息账本后端=postgres") + return store + except Exception as exc: # noqa: BLE001 + if backend == "postgres": + raise + logger.warning("PG 消息账本不可用,回退内存:%s", exc) + + logger.info("消息账本后端=memory") + return MemoryMessageStore() diff --git a/inquiry-agent/agent/channel/runtime.py b/inquiry-agent/agent/channel/runtime.py index 7e5cbdb..e3f58c6 100644 --- a/inquiry-agent/agent/channel/runtime.py +++ b/inquiry-agent/agent/channel/runtime.py @@ -1,5 +1,5 @@ """ -通道消费循环:inbox → echo handler;outbox → 企微发送。 +通道消费循环:inbox → RouteDecision → dispatch → handler;outbox → 企微发送。 本文件职责:在 HTTP 进程内独立线程跑快路径,不与 LibreOffice 串队。 禁止:在回调请求线程调用本循环的阻塞逻辑;禁止 Redis 全局单例锁。 @@ -12,9 +12,10 @@ import threading from typing import Optional from agent.channel.outbox.sender import PERMANENT_SEND_ERRORS, WeComAppClient -from agent.channel.queue import MemoryMessageStore, get_message_store +from agent.channel.queue import get_message_store from agent.config import Settings -from agent.handlers.echo_text import handle_echo_text +from agent.routing import IntentRouter +from agent.routing.dispatch import dispatch_inbound logger = logging.getLogger(__name__) # 避免 httpx 把 access_token / secret 打进 INFO @@ -26,6 +27,7 @@ class ChannelRuntime: 企微通道运行时:inbox/outbox 各一条守护线程。 启动时机:FastAPI lifespan;停止:进程退出。 + inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。 """ def __init__(self, settings: Settings) -> None: @@ -33,6 +35,7 @@ class ChannelRuntime: self._store = get_message_store() self._stop = threading.Event() self._threads: list[threading.Thread] = [] + self._router = IntentRouter(allow_network=False) agent_id = int(settings.wecom_agent_id or "0") or 0 self._client = WeComAppClient( corp_id=settings.wecom_corp_id, @@ -41,7 +44,7 @@ class ChannelRuntime: ) @property - def store(self) -> MemoryMessageStore: + def store(self): return self._store def start(self) -> None: @@ -53,7 +56,7 @@ class ChannelRuntime: ] for t in self._threads: t.start() - logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程)") + logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程;dispatch 分发)") def stop(self) -> None: self._stop.set() @@ -65,12 +68,11 @@ class ChannelRuntime: self._store.wait_inbox(1.0) continue try: - handle_echo_text( - item.message, - self._store, - ytd_env=self._settings.ytd_env, - reply_template=self._settings.wecom_callback_reply_text, + decision = self._router.route( + text=item.message.content or "", + sender_id=item.message.sender_id or "", ) + dispatch_inbound(item.message, decision) self._store.complete_inbound(item.id) except Exception as exc: # noqa: BLE001 logger.exception("inbox 处理失败 id=%s", item.id) diff --git a/inquiry-agent/agent/channel/store_protocol.py b/inquiry-agent/agent/channel/store_protocol.py new file mode 100644 index 0000000..108df3a --- /dev/null +++ b/inquiry-agent/agent/channel/store_protocol.py @@ -0,0 +1,42 @@ +""" +消息账本协议:inbox / outbox 认领接口。 + +Memory 与 Postgres 实现共用此协议,通道运行时不依赖具体后端。 +""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + +from agent.channel.queue import InboxItem, OutboxItem +from agent.channel.wecom.models import InboundMessage + + +class MessageStore(Protocol): + """inbox/outbox 账本协议。""" + + def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]: ... + + def claim_inbound(self) -> Optional[InboxItem]: ... + + def complete_inbound(self, item_id: str, *, error: str = "") -> None: ... + + def enqueue_outbound( + self, + *, + touser: str, + content: str, + dedupe_key: str = "", + ) -> tuple[bool, str]: ... + + def claim_outbound(self) -> Optional[OutboxItem]: ... + + def complete_outbound( + self, item_id: str, *, error: str = "", permanent: bool = False + ) -> None: ... + + def wait_inbox(self, timeout: float = 1.0) -> None: ... + + def wait_outbox(self, timeout: float = 1.0) -> None: ... + + def stats(self) -> dict[str, Any]: ... diff --git a/inquiry-agent/agent/channel/wecom/models.py b/inquiry-agent/agent/channel/wecom/models.py index 7c92f15..744d987 100644 --- a/inquiry-agent/agent/channel/wecom/models.py +++ b/inquiry-agent/agent/channel/wecom/models.py @@ -35,3 +35,34 @@ class InboundMessage: def has_sender(self) -> bool: """是否具备可进入业务的发送人身份。""" return bool(self.sender_id and self.sender_id.strip()) + + def to_dict(self) -> dict[str, Any]: + """序列化进 PG JSONB;不含密钥。""" + return { + "sender_id": self.sender_id, + "message_id": self.message_id, + "content": self.content, + "msg_type": self.msg_type, + "chat_id": self.chat_id, + "chat_type": self.chat_type, + "agent_id": self.agent_id, + "create_time": self.create_time, + "media": self.media, + "raw": self.raw, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InboundMessage": + """从 JSONB / dict 还原。""" + return cls( + sender_id=str(data.get("sender_id") or ""), + message_id=str(data.get("message_id") or ""), + content=str(data.get("content") or ""), + msg_type=str(data.get("msg_type") or "text"), + chat_id=str(data.get("chat_id") or ""), + chat_type=str(data.get("chat_type") or "single"), + agent_id=str(data.get("agent_id") or ""), + create_time=int(data.get("create_time") or 0), + media=dict(data.get("media") or {}), + raw=dict(data.get("raw") or {}), + ) diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 0acf72a..81dbd61 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -41,8 +41,14 @@ class Settings(BaseSettings): ytd_env: str = Field(default="test", alias="YTD_ENV") agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT") public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL") - biz_api_base_url: str = Field(default="http://127.0.0.1:8180", alias="BIZ_API_BASE_URL") + biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL") + biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN") + biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME") + biz_api_password: str = Field(default="", alias="BIZ_API_PASSWORD") + biz_api_timeout_seconds: float = Field(default=10.0, alias="BIZ_API_TIMEOUT_SECONDS") agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN") + # 兼容现网变量名 + inquiry_agent_callback_token: str = Field(default="", alias="INQUIRY_AGENT_CALLBACK_TOKEN") # 企微询价应用(与正式同一套应用 ID;密钥只从环境读) wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID") @@ -62,12 +68,78 @@ class Settings(BaseSettings): pg_database: str = Field(default="", alias="PG_DATABASE") pg_user: str = Field(default="", alias="PG_USER") pg_password: str = Field(default="", alias="PG_PASSWORD") + pg_sslmode: str = Field(default="prefer", alias="PG_SSLMODE") + # 可直接给完整 DSN;为空则由 host/port/user 等拼装 + pg_dsn: str = Field(default="", alias="PG_DSN") + # postgres | memory;正式必须 postgres;memory 仅本地骨架冒烟 + checkpoint_backend: str = Field(default="postgres", alias="CHECKPOINT_BACKEND") + redis_url: str = Field(default="", alias="REDIS_URL") redis_key_prefix: str = Field(default="inquiry_robot:", alias="REDIS_KEY_PREFIX") + # redis | memory;正式必须 redis + URL 指向逻辑库 /1(与现网 /0 隔离) + redis_backend: str = Field(default="redis", alias="REDIS_BACKEND") + redis_required: bool = Field(default=False, alias="REDIS_REQUIRED") + # auto | memory | postgres;auto=有 PG_DSN 则尝试 PG + message_store_backend: str = Field(default="auto", alias="MESSAGE_STORE_BACKEND") + wecom_aibot_bridge_token: str = Field(default="", alias="WECOM_AIBOT_BRIDGE_TOKEN") + wecom_aibot_ingress_url: str = Field( + default="http://127.0.0.1:8910/internal/aibot/inbound", + alias="WECOM_AIBOT_INGRESS_URL", + ) + + deepseek_api_key: str = Field(default="", alias="DEEPSEEK_API_KEY") + deepseek_base_url: str = Field(default="https://api.deepseek.com", alias="DEEPSEEK_BASE_URL") + deepseek_model: str = Field(default="", alias="DEEPSEEK_MODEL") + qwen_api_key: str = Field(default="", alias="QWEN_API_KEY") + qwen_base_url: str = Field(default="", alias="QWEN_BASE_URL") + qwen_text_model: str = Field(default="", alias="QWEN_TEXT_MODEL") + qwen_vl_model: str = Field(default="", alias="QWEN_VL_MODEL") + # prompt/04:未确认不得打真实模型(出站 HTTP 客户端强制检查) + llm_data_usage_confirmed: bool = Field(default=False, alias="LLM_DATA_USAGE_CONFIRMED") + # Worker 槽是否允许对 ordered_actions 走真网(仍受上一开关约束) + llm_allow_network: bool = Field(default=False, alias="LLM_ALLOW_NETWORK") + + # LibreOffice:默认 Windows 安装路径;Linux 可改 /usr/bin/libreoffice + libreoffice_path: str = Field( + default=r"C:\Program Files\LibreOffice\program\soffice.exe", + alias="LIBREOFFICE_PATH", + ) + libreoffice_timeout_seconds: float = Field( + default=120.0, alias="LIBREOFFICE_TIMEOUT_SECONDS" + ) + + object_storage_endpoint_url: str = Field(default="", alias="OBJECT_STORAGE_ENDPOINT_URL") + object_storage_bucket: str = Field(default="inquiry-robot", alias="OBJECT_STORAGE_BUCKET") + object_storage_access_key: str = Field(default="", alias="OBJECT_STORAGE_ACCESS_KEY") + object_storage_secret_key: str = Field(default="", alias="OBJECT_STORAGE_SECRET_KEY") + object_storage_path_style: bool = Field(default=True, alias="OBJECT_STORAGE_PATH_STYLE") libreoffice_slots: int = Field(default=1, alias="LIBREOFFICE_SLOTS") http_job_slots: int = Field(default=3, alias="HTTP_JOB_SLOTS") + def resolve_pg_dsn(self) -> str: + """ + 解析 PostgreSQL 连接串(运行账 / Graph checkpoint)。 + + 返回值:PG_DSN 或由分量拼出的 URI;缺关键字段时返回空串。 + 禁止:连现网 ytd_runtime(由部署/环境保证库名,代码不写死切换)。 + """ + raw = (self.pg_dsn or "").strip() + if raw: + return raw + if not (self.pg_host and self.pg_database and self.pg_user): + return "" + # 密码可为空(本机信任);sslmode 走环境 + from urllib.parse import quote_plus + + user = quote_plus(self.pg_user) + password = quote_plus(self.pg_password or "") + auth = f"{user}:{password}@" if self.pg_password else f"{user}@" + return ( + f"postgresql://{auth}{self.pg_host}:{self.pg_port}/" + f"{self.pg_database}?sslmode={self.pg_sslmode or 'prefer'}" + ) + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/inquiry-agent/agent/foundation_selftest.py b/inquiry-agent/agent/foundation_selftest.py new file mode 100644 index 0000000..45cda60 --- /dev/null +++ b/inquiry-agent/agent/foundation_selftest.py @@ -0,0 +1,262 @@ +""" +基础框架联合自检(不连真实 PG/外网模型也可跑)。 + +覆盖:迁移、正式合同、ledger、llm、DeepSeek A 解析、LibreOffice 探测、 +识别/存档入队、routing、storage。 +用法: + set REDIS_BACKEND=memory + set CHECKPOINT_BACKEND=memory + python -m agent.foundation_selftest +""" + +from __future__ import annotations + +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + os.environ["REDIS_BACKEND"] = "memory" + os.environ.setdefault("YTD_ENV", "test") + os.environ.setdefault("CHECKPOINT_BACKEND", "memory") + os.environ.setdefault("REDIS_KEY_PREFIX", "inquiry_robot:") + os.environ["LLM_DATA_USAGE_CONFIRMED"] = "false" + os.environ["LLM_ALLOW_NETWORK"] = "false" + + from agent.config import get_settings + from agent.jobs.archive import enqueue_archive + from agent.jobs.libreoffice import LibreOfficeConverter + from agent.jobs.recognition import enqueue_recognition + from agent.ledger import LedgerClient + from agent.llm import LlmGateway, LlmMode + from agent.redis_coord import shutdown_redis_runtime + from agent.routing import IntentRouter, ordered_actions_tool_definition, parse_ordered_actions_arguments + from agent.runtime_db import assert_safe_database, list_migration_files + from agent.schema import load_contract, require_formal_contract + from agent.storage import ObjectStorageClient + + get_settings.cache_clear() + settings = get_settings() + + files = list_migration_files() + assert "001_runtime_core_v1.sql" in files, files + assert "002_dead_letter_v1.sql" in files, files + logger.info("OK: migrations %s", files) + + try: + assert_safe_database("postgresql://u:p@127.0.0.1:5432/ytd_runtime") + logger.error("应拒绝 ytd_runtime") + return 1 + except RuntimeError: + logger.info("OK: 拒绝现网库名 ytd_runtime") + + contract = load_contract("inquiry-required-fields-v1") + assert contract["_meta"]["is_placeholder"] is False, contract["_meta"] + formal = require_formal_contract("inquiry-required-fields-v1") + assert formal.get("contract_id") or formal.get("schema_version") is not None + tms = require_formal_contract("tms-dictionary-projection-v2") + assert tms["_meta"]["is_placeholder"] is False + logger.info("OK: 正式合同已加载 fields+tms") + + tool = ordered_actions_tool_definition() + assert tool["function"]["name"] == "route_decision" + digest = "a" * 64 + parsed = parse_ordered_actions_arguments( + { + "decision_id": "D-1", + "intent": "ordinary_text_inquiry", + "ordered_action": { + "action_type": "ORDINARY_TEXT_INQUIRY", + "order": 1, + "target": {"work_order_no": ""}, + "evidence": {"source_ref": "inbound:u1", "content_digest": digest}, + }, + } + ) + assert parsed.intent == "ordinary_text_inquiry" + logger.info("OK: ordered_actions 解析") + + ledger = LedgerClient.from_settings(settings) + assert "8180" in ledger.base_url or ledger.base_url + logger.info("OK: ledger client base=%s", ledger.base_url) + ping = ledger.ping() + logger.info("ledger.ping ok=%s status=%s", ping.ok, ping.status_code) + ledger.close() + + gw = LlmGateway.from_settings(settings) + enq = gw.enqueue( + mode=LlmMode.ORDERED_ACTIONS, + payload={"text": "hi"}, + idempotency_key="foundation-1", + ) + assert enq.accepted, enq + stub = gw.invoke_stub(mode=LlmMode.EXTRACT_FIELDS, payload={"a": 1}) + assert stub.get("stub") is True + assert stub.get("error") == "extract_fields_shell_not_implemented" + from agent.llm import list_mode_shells + + assert "vision" in list_mode_shells() + logger.info("OK: llm enqueue/mode shells") + + decision = IntentRouter().route_stub(text="询价", sender_id="u1") + assert decision.intent == "ordinary_text_inquiry", decision.intent + assert IntentRouter().route(text="询价", sender_id="").intent == "reject_no_sender" + logger.info("OK: DeepSeek A stub 路由") + + from agent.channel.clarification import issue_token, validate_token + from agent.channel.queue import reset_message_store_for_tests + from agent.channel.wecom.models import InboundMessage + + reset_message_store_for_tests() + os.environ["MESSAGE_STORE_BACKEND"] = "memory" + get_settings.cache_clear() + reset_message_store_for_tests() + from agent.channel.queue import get_message_store + + store = get_message_store() + assert store.stats().get("backend") == "memory" + msg = InboundMessage(sender_id="u1", message_id="m-foundation-1", content="hello") + ok, iid = store.enqueue_inbound(msg) + assert ok, iid + claimed = store.claim_inbound() + assert claimed is not None + store.complete_inbound(claimed.id) + logger.info("OK: memory inbox claim") + + tok = issue_token( + thread_id="t1", + wait_version=1, + sender_id="u1", + allowed_actions=["submit_clarify"], + ) + rec = validate_token(tok, action="submit_clarify", consume=True) + assert rec is not None and rec.thread_id == "t1" + assert validate_token(tok) is None + logger.info("OK: clarification token") + + from agent.graph import GraphRuntime, shutdown_graph_runtime + from agent.graph.wake import WorkerWakePayload + from agent.jobs.wake_consumer import claim_and_apply_wake, enqueue_wake, wake_stream + from agent.llm import LlmHttpClient + from agent.redis_coord import get_redis_runtime + + os.environ["CHECKPOINT_BACKEND"] = "memory" + get_settings.cache_clear() + gr = GraphRuntime.create(get_settings()) + started = gr.start_thread(sender_id="u1") + tid = started["thread_id"] + rr = get_redis_runtime(get_settings()) + entry = enqueue_wake( + rr.client, + WorkerWakePayload( + thread_id=tid, + inquiry_no="F-1", + quote_version=1, + wait_version=0, + kind="recognition_done", + ), + ) + assert entry + q = wake_stream(rr.client) + assert claim_and_apply_wake(queue=q, graph=gr, consumer="selftest") + logger.info("OK: wake enqueue/apply") + + http_llm = LlmHttpClient.from_settings(get_settings()) + no_net = http_llm.chat(mode="ordered_actions", messages=[{"role": "user", "content": "hi"}]) + assert no_net.ok is False and no_net.error == "llm_data_usage_not_confirmed" + logger.info("OK: llm http 拒绝未确认数据用途") + + recog = enqueue_recognition( + kind="attachment_text", + payload={"object_key": "stub"}, + idempotency_key="recog-1", + redis=rr, + ) + assert recog.accepted, recog + arch = enqueue_archive(payload={"msg_id": "m1"}, idempotency_key="arch-1", redis=rr) + assert arch.accepted, arch + logger.info("OK: recognition/archive enqueue") + + lo = LibreOfficeConverter.from_settings(get_settings()) + logger.info("OK: LibreOffice available=%s path=%s", lo.available(), lo.executable()) + + gr.close() + shutdown_graph_runtime() + + storage = ObjectStorageClient.from_settings(settings) + put_ok = storage.put_bytes_stub(key="x", data=b"1") + assert put_ok is storage.configured() + logger.info("OK: storage stub configured=%s put_ok=%s", storage.configured(), put_ok) + + from agent.jobs.dead_letter import enqueue_dead_letter + from agent.jobs.pg_job import create_job_store + from agent.ledger.api_shell import LedgerApiShell + from agent.policy import assert_has_sender_id, policy_package_ready + from agent.quote_templates import list_template_registry + from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound + from agent.runtime_db import SessionRecord, create_session_store, create_suspend_store + from agent.runtime_db.suspend_store import SuspendRecord + from agent.schema import validate_required_fields_shell + from agent.storage.s3_client import S3ObjectStorage + + assert policy_package_ready() + assert assert_has_sender_id("u1")["ok"] is True + assert assert_has_sender_id("")["ok"] is False + + sess = create_session_store(backend="memory") + sess.upsert(SessionRecord(thread_id="t-shell", sender_id="u1")) + assert sess.get("t-shell") is not None + + sus = create_suspend_store(backend="memory") + sus.open_wait(SuspendRecord(thread_id="t-shell", wait_version=1, pause_name="pause_parse_clarify")) + assert sus.get("t-shell", 1) is not None + assert sus.close_wait("t-shell", 1) is True + + jobs = create_job_store(backend="memory") + ok_j, jid = jobs.enqueue(job_type="shell", payload={"x": 1}, idempotency_key="j1") + assert ok_j, jid + claimed_j = jobs.claim(worker_id="selftest") + assert claimed_j is not None + jobs.complete(claimed_j.id) + + dl = enqueue_dead_letter(source="selftest", error="demo", job_type="shell") + assert dl + + assert "ordinary_text_inquiry" in HANDLER_REGISTRY + d2 = IntentRouter().route_stub(text="x", sender_id="u1") + msg2 = InboundMessage(sender_id="u1", message_id="m-dispatch", content="x") + # dispatch 可能写 outbox(echo);仅验证查表调用不抛 + dispatch_inbound(msg2, d2) + + shell_api = LedgerApiShell(LedgerClient.from_settings(get_settings())) + assert shell_api.create_ticket(payload={}, idempotency_key="x").status_code == 501 + + from agent.quote_templates import list_templates_from_ledger + + lt = list_templates_from_ledger(LedgerClient.from_settings(get_settings())) + assert lt.status_code == 501 + assert lt.error and "list_quote_templates" in lt.error + + vf = validate_required_fields_shell(facts={}, require_formal=True) + assert vf.get("implemented") is False + + _ = list_template_registry() + s3 = S3ObjectStorage.from_settings(get_settings(), allow_network=False) + put_s3 = s3.put_bytes(key="k", data=b"1") + assert put_s3.get("dry_run") is True or put_s3.get("error") in { + "not_configured", + "boto3_not_installed", + } + logger.info("OK: session/suspend/job/dead_letter/dispatch/ledger/schema/s3 shells") + + shutdown_redis_runtime() + logger.info("基础框架联合自检通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/inquiry-agent/agent/graph/__init__.py b/inquiry-agent/agent/graph/__init__.py index 45d59e7..bf0da7d 100644 --- a/inquiry-agent/agent/graph/__init__.py +++ b/inquiry-agent/agent/graph/__init__.py @@ -1,6 +1,29 @@ """ -LangGraph 编排:图定义与节点分文件;checkpoint 在 PostgreSQL。 +LangGraph 编排包:图定义、节点分文件、pause/resume、结构化 wake。 -禁止:单文件堆全部节点;禁止在本包处理企微 HTTP 或 LibreOffice。 -骨架:仅包占位,不注册图。 +本包职责(对齐 prompt/14、06): +- 一单一个 thread_id;checkpoint 在 PostgreSQL(可开发降级 Memory); +- 停点 interrupt_before;Worker 槽内 start/resume/wake; +- 无 sender_id 不进图。 + +禁止:企微 HTTP、LibreOffice、主账六态真相、单文件堆全部节点业务。 +骨架:节点仅打日志;HTTP 回调进程不要 import 后 invoke 整图。 """ + +from agent.graph.runtime import ( + GraphIdentityError, + GraphRuntime, + GraphWakeError, + get_graph_runtime, + shutdown_graph_runtime, +) +from agent.graph.wake import WorkerWakePayload + +__all__ = [ + "GraphIdentityError", + "GraphRuntime", + "GraphWakeError", + "WorkerWakePayload", + "get_graph_runtime", + "shutdown_graph_runtime", +] diff --git a/inquiry-agent/agent/graph/builder.py b/inquiry-agent/agent/graph/builder.py new file mode 100644 index 0000000..822a2e1 --- /dev/null +++ b/inquiry-agent/agent/graph/builder.py @@ -0,0 +1,76 @@ +""" +装配询价 StateGraph(骨架线性流水线)。 + +本文件职责:把分文件节点串成一张图并 compile;不写业务分支。 +停点:interrupt_before=INTERRUPT_BEFORE(见 pauses.py)。 +禁止:单文件堆全部节点实现;禁止在本文件调企微 / soffice。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from langgraph.graph import END, START, StateGraph + +from agent.graph import nodes as graph_nodes +from agent.graph.pauses import ( + INTERRUPT_BEFORE, + PAUSE_ADOPT_ADJUST, + PAUSE_CLOSE_DEAL, + PAUSE_COLLABORATE, + PAUSE_CREATE_ORDER, + PAUSE_GENERATE_QUOTE, + PAUSE_PARSE_CLARIFY, + PAUSE_QUERY_QUOTE, +) +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def build_state_graph() -> StateGraph: + """ + 构建未编译的状态图。 + + 节点顺序对齐 prompt/14:解析/补问 → 建单 → 查价 → 协同 → 报价 → 采用/调价 → 成交。 + 骨架为直线边;后续可加条件边,勿在此塞主账逻辑。 + """ + g: StateGraph = StateGraph(InquiryGraphState) + g.add_node(PAUSE_PARSE_CLARIFY, graph_nodes.node_parse_clarify) + g.add_node(PAUSE_CREATE_ORDER, graph_nodes.node_create_order) + g.add_node(PAUSE_QUERY_QUOTE, graph_nodes.node_query_quote) + g.add_node(PAUSE_COLLABORATE, graph_nodes.node_collaborate) + g.add_node(PAUSE_GENERATE_QUOTE, graph_nodes.node_generate_quote) + g.add_node(PAUSE_ADOPT_ADJUST, graph_nodes.node_adopt_adjust) + g.add_node(PAUSE_CLOSE_DEAL, graph_nodes.node_close_deal) + + g.add_edge(START, PAUSE_PARSE_CLARIFY) + g.add_edge(PAUSE_PARSE_CLARIFY, PAUSE_CREATE_ORDER) + g.add_edge(PAUSE_CREATE_ORDER, PAUSE_QUERY_QUOTE) + g.add_edge(PAUSE_QUERY_QUOTE, PAUSE_COLLABORATE) + g.add_edge(PAUSE_COLLABORATE, PAUSE_GENERATE_QUOTE) + g.add_edge(PAUSE_GENERATE_QUOTE, PAUSE_ADOPT_ADJUST) + g.add_edge(PAUSE_ADOPT_ADJUST, PAUSE_CLOSE_DEAL) + g.add_edge(PAUSE_CLOSE_DEAL, END) + return g + + +def compile_inquiry_graph(*, checkpointer: Any) -> Any: + """ + 编译可执行图。 + + 参数:LangGraph checkpointer(Postgres 或 Memory)。 + 返回:CompiledGraph;带 interrupt_before。 + 线程:应在 Worker 启动线程调用一次,缓存复用。 + """ + graph = build_state_graph() + compiled = graph.compile( + checkpointer=checkpointer, + interrupt_before=list(INTERRUPT_BEFORE), + ) + logger.info( + "询价 Graph 已编译 interrupt_before=%s", + list(INTERRUPT_BEFORE), + ) + return compiled diff --git a/inquiry-agent/agent/graph/checkpoint.py b/inquiry-agent/agent/graph/checkpoint.py new file mode 100644 index 0000000..a70077a --- /dev/null +++ b/inquiry-agent/agent/graph/checkpoint.py @@ -0,0 +1,91 @@ +""" +Checkpoint 工厂:PostgreSQL 优先,开发可降级内存。 + +本文件职责:按 CHECKPOINT_BACKEND / PG_DSN 创建 LangGraph checkpointer。 +副作用:postgres 模式会 setup() 建 checkpoint 表(仅当前 PG_DATABASE)。 +禁止:把 checkpoint 写入 Redis;禁止指向现网 ytd_runtime。 +线程:Worker 进程启动时创建一次,供多槽共享(同一 thread_id 写冲突由 runtime 锁)。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.config import Settings + +logger = logging.getLogger(__name__) + + +@dataclass +class CheckpointBundle: + """ + checkpointer 与可选连接资源。 + + postgres 模式下保留 context/cm 以便进程退出时关闭连接池。 + """ + + checkpointer: Any + backend: str + _closer: Optional[Any] = None + + def close(self) -> None: + """释放 postgres 连接(若有)。无副作用于业务数据。""" + if self._closer is None: + return + try: + # from_conn_string 返回上下文管理器 + self._closer.__exit__(None, None, None) + except Exception: # noqa: BLE001 — 关闭失败只记日志 + logger.exception("关闭 Postgres checkpointer 失败") + self._closer = None + +def create_checkpointer(settings: Settings) -> CheckpointBundle: + """ + 创建 checkpointer。 + + - CHECKPOINT_BACKEND=memory:仅本地骨架冒烟,进程重启丢书签。 + - postgres:需要可连的 resolve_pg_dsn();失败时若 ytd_env=test 可降级 memory 并打警告。 + """ + backend = (settings.checkpoint_backend or "postgres").strip().lower() + if backend == "memory": + from langgraph.checkpoint.memory import MemorySaver + + logger.warning("Graph checkpoint 使用 MemorySaver(仅骨架/本机;正式必须 postgres)") + return CheckpointBundle(checkpointer=MemorySaver(), backend="memory") + + dsn = settings.resolve_pg_dsn() + if not dsn: + if (settings.ytd_env or "").lower() == "prod": + raise RuntimeError("正式环境必须配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER") + from langgraph.checkpoint.memory import MemorySaver + + logger.warning( + "未配置 PG_DSN,ytd_env=%s 降级 MemorySaver;正式切流前必须接 inquiry_robot_runtime", + settings.ytd_env, + ) + return CheckpointBundle(checkpointer=MemorySaver(), backend="memory") + + try: + from langgraph.checkpoint.postgres import PostgresSaver + + # from_conn_string 返回上下文管理器;进入后得到 saver,并需保持打开 + cm = PostgresSaver.from_conn_string(dsn) + saver = cm.__enter__() + saver.setup() + logger.info( + "Graph Postgres checkpointer 就绪 database=%s", + settings.pg_database or "(from DSN)", + ) + return CheckpointBundle(checkpointer=saver, backend="postgres", _closer=cm) + except Exception as exc: # noqa: BLE001 + if (settings.ytd_env or "").lower() == "prod": + raise + from langgraph.checkpoint.memory import MemorySaver + + logger.warning( + "Postgres checkpointer 初始化失败,降级 MemorySaver:%s", + exc, + ) + return CheckpointBundle(checkpointer=MemorySaver(), backend="memory") diff --git a/inquiry-agent/agent/graph/nodes/__init__.py b/inquiry-agent/agent/graph/nodes/__init__.py new file mode 100644 index 0000000..781edd5 --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/__init__.py @@ -0,0 +1,23 @@ +""" +Graph 节点包:一阶段一文件。 + +本包只做编排占位;自然语言抽取、主账写、LibreOffice 不在本包实现。 +""" + +from agent.graph.nodes.adopt_adjust import node_adopt_adjust +from agent.graph.nodes.close_deal import node_close_deal +from agent.graph.nodes.collaborate import node_collaborate +from agent.graph.nodes.create_order import node_create_order +from agent.graph.nodes.generate_quote import node_generate_quote +from agent.graph.nodes.parse_clarify import node_parse_clarify +from agent.graph.nodes.query_quote import node_query_quote + +__all__ = [ + "node_parse_clarify", + "node_create_order", + "node_query_quote", + "node_collaborate", + "node_generate_quote", + "node_adopt_adjust", + "node_close_deal", +] diff --git a/inquiry-agent/agent/graph/nodes/adopt_adjust.py b/inquiry-agent/agent/graph/nodes/adopt_adjust.py new file mode 100644 index 0000000..490c086 --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/adopt_adjust.py @@ -0,0 +1,23 @@ +""" +节点:采用 / 调价(骨架)。 + +正式:卡片动作 + waitVersion 校验后调主账报价版本;本文件不写主账。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_adopt_adjust(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=adopt_adjust。""" + logger.info( + "graph.node adopt_adjust thread_id=%s(骨架无采用/调价)", + state.get("thread_id") or "", + ) + return {"phase": "adopt_adjust"} diff --git a/inquiry-agent/agent/graph/nodes/close_deal.py b/inquiry-agent/agent/graph/nodes/close_deal.py new file mode 100644 index 0000000..4347593 --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/close_deal.py @@ -0,0 +1,23 @@ +""" +节点:成交 / 未成交收尾(骨架)。 + +正式:调主账闭环;图结束 ≠ 主账已成交。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_close_deal(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=close_deal。""" + logger.info( + "graph.node close_deal thread_id=%s(骨架无闭环)", + state.get("thread_id") or "", + ) + return {"phase": "close_deal"} diff --git a/inquiry-agent/agent/graph/nodes/collaborate.py b/inquiry-agent/agent/graph/nodes/collaborate.py new file mode 100644 index 0000000..0e80b29 --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/collaborate.py @@ -0,0 +1,23 @@ +""" +节点:协同 / 群报价等待(骨架)。 + +正式:等群内报价或协同结果;用户文字不得冒充文件完成(wake 另走结构化载荷)。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_collaborate(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=collaborate。""" + logger.info( + "graph.node collaborate thread_id=%s(骨架无协同)", + state.get("thread_id") or "", + ) + return {"phase": "collaborate"} diff --git a/inquiry-agent/agent/graph/nodes/create_order.py b/inquiry-agent/agent/graph/nodes/create_order.py new file mode 100644 index 0000000..8259d7c --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/create_order.py @@ -0,0 +1,24 @@ +""" +节点:建单准备(骨架)。 + +正式职责将是:调用主账建单 API;六态成功只认主账返回,图成功不能冒充。 +本文件不写 MySQL、不直连 TMS。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_create_order(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=create_order。""" + logger.info( + "graph.node create_order thread_id=%s(骨架无建单)", + state.get("thread_id") or "", + ) + return {"phase": "create_order"} diff --git a/inquiry-agent/agent/graph/nodes/generate_quote.py b/inquiry-agent/agent/graph/nodes/generate_quote.py new file mode 100644 index 0000000..cb61100 --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/generate_quote.py @@ -0,0 +1,23 @@ +""" +节点:报价文件生成等待(骨架)。 + +正式:等 Worker LibreOffice/对象存储完成后的结构化 wake;本节点不跑 soffice。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_generate_quote(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=generate_quote。""" + logger.info( + "graph.node generate_quote thread_id=%s(骨架不转 PDF)", + state.get("thread_id") or "", + ) + return {"phase": "generate_quote"} diff --git a/inquiry-agent/agent/graph/nodes/parse_clarify.py b/inquiry-agent/agent/graph/nodes/parse_clarify.py new file mode 100644 index 0000000..9f5493a --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/parse_clarify.py @@ -0,0 +1,27 @@ +""" +节点:解析 / 补问阶段(骨架)。 + +正式职责将是:触发字段合同检查与补问等待;本文件不抽字段、不调模型。 +线程:仅在 Worker Graph 槽内执行,禁止占用企微回调线程。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_parse_clarify(state: InquiryGraphState) -> dict[str, Any]: + """ + 骨架节点:标记 phase=parse_clarify,不产生业务副作用。 + + 参数:当前 checkpoint 状态。 + 返回:状态增量(合并进 checkpoint)。 + """ + thread_id = state.get("thread_id") or "" + logger.info("graph.node parse_clarify thread_id=%s(骨架无业务)", thread_id) + return {"phase": "parse_clarify"} diff --git a/inquiry-agent/agent/graph/nodes/query_quote.py b/inquiry-agent/agent/graph/nodes/query_quote.py new file mode 100644 index 0000000..2a6bb1f --- /dev/null +++ b/inquiry-agent/agent/graph/nodes/query_quote.py @@ -0,0 +1,23 @@ +""" +节点:查价 / TMS 结果编排(骨架)。 + +正式:经主账查价;分类结果给人选择。禁止在本节点直连 TMS。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.graph.state import InquiryGraphState + +logger = logging.getLogger(__name__) + + +def node_query_quote(state: InquiryGraphState) -> dict[str, Any]: + """骨架:仅标记 phase=query_quote。""" + logger.info( + "graph.node query_quote thread_id=%s(骨架无查价)", + state.get("thread_id") or "", + ) + return {"phase": "query_quote"} diff --git a/inquiry-agent/agent/graph/pauses.py b/inquiry-agent/agent/graph/pauses.py new file mode 100644 index 0000000..96da3da --- /dev/null +++ b/inquiry-agent/agent/graph/pauses.py @@ -0,0 +1,29 @@ +""" +Graph 停靠点名称(interrupt_before)。 + +本文件职责:集中声明 pause_* 节点名,与 prompt/14「停点中断」对齐。 +调用时机:builder.compile(interrupt_before=INTERRUPT_BEFORE)。 +禁止:在停点名上写业务 if/else;业务唤醒校验走 waitVersion(runtime/wake)。 +""" + +from __future__ import annotations + +# 节点名 = builder 里 add_node 的名字;interrupt_before 表示「跑该节点前停下等人/等文件」 +PAUSE_PARSE_CLARIFY = "pause_parse_clarify" +PAUSE_CREATE_ORDER = "pause_create_order" +PAUSE_QUERY_QUOTE = "pause_query_quote" +PAUSE_COLLABORATE = "pause_collaborate" +PAUSE_GENERATE_QUOTE = "pause_generate_quote" +PAUSE_ADOPT_ADJUST = "pause_adopt_adjust" +PAUSE_CLOSE_DEAL = "pause_close_deal" + +# 骨架:全部标为可中断;后续可按业务收窄列表 +INTERRUPT_BEFORE: tuple[str, ...] = ( + PAUSE_PARSE_CLARIFY, + PAUSE_CREATE_ORDER, + PAUSE_QUERY_QUOTE, + PAUSE_COLLABORATE, + PAUSE_GENERATE_QUOTE, + PAUSE_ADOPT_ADJUST, + PAUSE_CLOSE_DEAL, +) diff --git a/inquiry-agent/agent/graph/runtime.py b/inquiry-agent/agent/graph/runtime.py new file mode 100644 index 0000000..bbc23cc --- /dev/null +++ b/inquiry-agent/agent/graph/runtime.py @@ -0,0 +1,197 @@ +""" +Graph 运行时公开 API:start / resume / wake。 + +本文件职责: +- Worker 进程内持有已编译图; +- 按 thread_id 启停;无 sender_id 拒绝进图; +- wake 只接受结构化 WorkerWakePayload。 + +禁止:在 HTTP 回调线程调用 invoke/resume(会卡企微);同一 thread_id 写冲突用细粒度锁。 +骨架:节点无业务,invoke 会在第一个 interrupt_before 停下。 +""" + +from __future__ import annotations + +import logging +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any, Optional + +from agent.config import Settings, get_settings +from agent.graph.builder import compile_inquiry_graph +from agent.graph.checkpoint import CheckpointBundle, create_checkpointer +from agent.graph.wake import WorkerWakePayload + +logger = logging.getLogger(__name__) + + +class GraphIdentityError(ValueError): + """无 sender_id 或其他身份不满足时抛出。""" + + +class GraphWakeError(ValueError): + """结构化 wake 校验失败。""" + + +@dataclass +class GraphRuntime: + """ + 进程级 Graph 句柄。 + + 生命周期:Worker 启动 create(),停止 close()。 + 并发:不同 thread_id 可并行;同一 thread_id 串行(_thread_locks)。 + """ + + settings: Settings + compiled: Any + checkpoint: CheckpointBundle + _thread_locks: dict[str, threading.Lock] = field(default_factory=dict) + _locks_guard: threading.Lock = field(default_factory=threading.Lock) + + @classmethod + def create(cls, settings: Optional[Settings] = None) -> "GraphRuntime": + """ + 编译图并挂 checkpointer。 + + 副作用:可能连接 PG 并 setup checkpoint 表;失败策略见 create_checkpointer。 + """ + cfg = settings or get_settings() + bundle = create_checkpointer(cfg) + compiled = compile_inquiry_graph(checkpointer=bundle.checkpointer) + logger.info( + "GraphRuntime 就绪 checkpoint_backend=%s ytd_env=%s", + bundle.backend, + cfg.ytd_env, + ) + return cls(settings=cfg, compiled=compiled, checkpoint=bundle) + + def close(self) -> None: + """关闭 checkpointer 连接。""" + self.checkpoint.close() + + def _lock_for(self, thread_id: str) -> threading.Lock: + """取得同一 thread_id 的互斥锁(禁止全局大锁串行所有工单)。""" + with self._locks_guard: + lock = self._thread_locks.get(thread_id) + if lock is None: + lock = threading.Lock() + self._thread_locks[thread_id] = lock + return lock + + @staticmethod + def _require_sender_id(sender_id: str) -> str: + sid = (sender_id or "").strip() + if not sid: + raise GraphIdentityError("无 sender_id,拒绝进入 Graph(prompt/09、14)") + return sid + + def start_thread( + self, + *, + sender_id: str, + thread_id: Optional[str] = None, + inquiry_no: Optional[str] = None, + ) -> dict[str, Any]: + """ + 新开一条询价图(新 thread_id)。 + + 调用线程:必须是 Worker HTTP 类槽,禁止企微回调线程。 + 副作用:写入 checkpoint;骨架会在首个 pause 中断。 + 返回:含 thread_id 与 invoke 原始结果摘要。 + """ + sid = self._require_sender_id(sender_id) + tid = (thread_id or "").strip() or str(uuid.uuid4()) + config = {"configurable": {"thread_id": tid}} + initial = { + "thread_id": tid, + "sender_id": sid, + "inquiry_no": inquiry_no, + "wait_version": 0, + "phase": "start", + } + lock = self._lock_for(tid) + with lock: + # 骨架:invoke 至 interrupt_before 第一个停点 + result = self.compiled.invoke(initial, config=config) + logger.info("graph.start_thread thread_id=%s sender_id=%s", tid, sid) + return {"thread_id": tid, "state": result, "interrupted": True} + + def resume_thread( + self, + *, + thread_id: str, + sender_id: str, + values: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + 从精确断点继续(入站「继续本单」路径的图侧入口)。 + + 无业务路由:values 仅合并进状态;真正 Handler 后续再接。 + """ + sid = self._require_sender_id(sender_id) + tid = (thread_id or "").strip() + if not tid: + raise GraphIdentityError("resume 缺少 thread_id") + config = {"configurable": {"thread_id": tid}} + lock = self._lock_for(tid) + with lock: + patch = dict(values or {}) + patch["sender_id"] = sid + patch["thread_id"] = tid + # None 表示从 checkpoint 继续;有 patch 时先 update 再跑 + if patch: + self.compiled.update_state(config, patch) + result = self.compiled.invoke(None, config=config) + logger.info("graph.resume_thread thread_id=%s", tid) + return {"thread_id": tid, "state": result} + + def wake_from_worker(self, payload: WorkerWakePayload) -> dict[str, Any]: + """ + Worker 结构化唤醒(识别/PDF 完成)。 + + 拒绝:缺 inquiry_no/quoteVersion/waitVersion;用户纯文字不得走本 API。 + """ + try: + patch = payload.as_state_patch() + except ValueError as exc: + raise GraphWakeError(str(exc)) from exc + tid = payload.thread_id.strip() + config = {"configurable": {"thread_id": tid}} + lock = self._lock_for(tid) + with lock: + self.compiled.update_state(config, patch) + result = self.compiled.invoke(None, config=config) + logger.info( + "graph.wake_from_worker thread_id=%s kind=%s wait_version=%s", + tid, + payload.kind, + payload.wait_version, + ) + return {"thread_id": tid, "state": result, "wake": patch["last_wake"]} + + +_runtime_singleton: Optional[GraphRuntime] = None +_runtime_guard = threading.Lock() + + +def get_graph_runtime(settings: Optional[Settings] = None) -> GraphRuntime: + """ + 进程内单例 GraphRuntime(懒创建)。 + + 供 Worker 与后续 jobs 槽使用;HTTP 进程不应依赖本单例去 invoke。 + """ + global _runtime_singleton + with _runtime_guard: + if _runtime_singleton is None: + _runtime_singleton = GraphRuntime.create(settings) + return _runtime_singleton + + +def shutdown_graph_runtime() -> None: + """进程退出时关闭单例。""" + global _runtime_singleton + with _runtime_guard: + if _runtime_singleton is not None: + _runtime_singleton.close() + _runtime_singleton = None diff --git a/inquiry-agent/agent/graph/selftest.py b/inquiry-agent/agent/graph/selftest.py new file mode 100644 index 0000000..7bd9bd3 --- /dev/null +++ b/inquiry-agent/agent/graph/selftest.py @@ -0,0 +1,68 @@ +""" +Graph 骨架自检:编译 + start_thread(Memory)+ 无 sender_id 拒绝。 + +用法(在 inquiry-agent 目录、已装依赖): + set CHECKPOINT_BACKEND=memory + python -m agent.graph.selftest + +不连企微、不连主账;正式验收仍以真实企微事件为准。 +""" + +from __future__ import annotations + +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + # 自检强制内存,避免误连生产 PG + os.environ["CHECKPOINT_BACKEND"] = "memory" + os.environ.setdefault("YTD_ENV", "test") + + from agent.config import get_settings + from agent.graph.runtime import GraphIdentityError, GraphRuntime, shutdown_graph_runtime + from agent.graph.wake import WorkerWakePayload + + get_settings.cache_clear() + rt = GraphRuntime.create(get_settings()) + try: + try: + rt.start_thread(sender_id="") + logger.error("预期应拒绝空 sender_id") + return 1 + except GraphIdentityError: + logger.info("OK: 空 sender_id 已拒绝") + + started = rt.start_thread(sender_id="selftest_user") + tid = started["thread_id"] + logger.info("OK: start_thread thread_id=%s", tid) + + # 骨架:wake 需要已有 thread;写入 last_wake 后再 resume 一步 + wake = WorkerWakePayload( + thread_id=tid, + inquiry_no="SELFTEST-001", + quote_version=1, + wait_version=0, + kind="recognition_done", + ) + woken = rt.wake_from_worker(wake) + logger.info("OK: wake_from_worker kind=%s", woken["wake"]["kind"]) + + resumed = rt.resume_thread(thread_id=tid, sender_id="selftest_user") + logger.info("OK: resume_thread thread_id=%s", resumed["thread_id"]) + logger.info( + "Graph 骨架自检通过 checkpoint_backend=%s", + rt.checkpoint.backend, + ) + return 0 + finally: + rt.close() + shutdown_graph_runtime() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/inquiry-agent/agent/graph/state.py b/inquiry-agent/agent/graph/state.py new file mode 100644 index 0000000..a46b6d5 --- /dev/null +++ b/inquiry-agent/agent/graph/state.py @@ -0,0 +1,32 @@ +""" +询价 Graph 状态(骨架)。 + +本文件职责:定义一条询价在图内携带的最小字段;checkpoint 会持久化本结构。 +禁止:把主账六态真相写进本状态并当作已成功;禁止在本文件写业务分支。 +""" + +from __future__ import annotations + +from typing import Any, Optional, TypedDict + + +class InquiryGraphState(TypedDict, total=False): + """ + 一单一份图状态。 + + thread_id:LangGraph / checkpoint 主键(一询价一 thread)。 + sender_id:入站企微 userid;为空时 runtime 拒绝进图。 + wait_version:与补问卡/采用卡绑定;错版本点击应丢弃(业务层后续实现)。 + last_wake:最近一次 Worker 结构化唤醒快照(仅记录,不替代主账)。 + """ + + thread_id: str + sender_id: str + inquiry_no: Optional[str] + wait_version: int + quote_version: Optional[int] + # 骨架占位:后续由各节点写入阶段标记;不得冒充主账六态 + phase: str + last_wake: dict[str, Any] + # 调试备注,正式路径勿依赖 + notes: str diff --git a/inquiry-agent/agent/graph/wake.py b/inquiry-agent/agent/graph/wake.py new file mode 100644 index 0000000..bdd3cbf --- /dev/null +++ b/inquiry-agent/agent/graph/wake.py @@ -0,0 +1,61 @@ +""" +Worker 结构化唤醒载荷。 + +本文件职责:定义「识别/PDF 完成」唤醒图的数据结构;禁止用用户聊天文字冒充文件完成。 +调用方:jobs 槽完成后 → GraphRuntime.wake_from_worker。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass(frozen=True) +class WorkerWakePayload: + """ + 结构化 wake(对齐 prompt/14)。 + + inquiry_no + quote_version + wait_version 三者用于对齐停点;缺任一则 runtime 拒绝。 + kind:识别完成 / PDF 完成等枚举字符串,骨架只做透传校验。 + """ + + thread_id: str + inquiry_no: str + quote_version: int + wait_version: int + kind: str + detail: Optional[dict[str, Any]] = None + + def validate(self) -> None: + """ + 校验必填字段。 + + 副作用:无效时抛 ValueError;不写库。 + """ + if not (self.thread_id or "").strip(): + raise ValueError("wake 缺少 thread_id") + if not (self.inquiry_no or "").strip(): + raise ValueError("wake 缺少 inquiry_no") + if self.quote_version is None or int(self.quote_version) < 0: + raise ValueError("wake quote_version 无效") + if self.wait_version is None or int(self.wait_version) < 0: + raise ValueError("wake wait_version 无效") + if not (self.kind or "").strip(): + raise ValueError("wake 缺少 kind") + + def as_state_patch(self) -> dict[str, Any]: + """转为写入图状态的增量(骨架:只记录 last_wake,不推进六态)。""" + self.validate() + return { + "inquiry_no": self.inquiry_no, + "quote_version": int(self.quote_version), + "wait_version": int(self.wait_version), + "last_wake": { + "kind": self.kind, + "inquiry_no": self.inquiry_no, + "quote_version": int(self.quote_version), + "wait_version": int(self.wait_version), + "detail": self.detail or {}, + }, + } diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py index afc5369..3d1d329 100644 --- a/inquiry-agent/agent/handlers/__init__.py +++ b/inquiry-agent/agent/handlers/__init__.py @@ -2,5 +2,9 @@ Handler 包:一类意图 / 一类卡片一个模块。 禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。 -骨架:仅包占位。 +当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。 """ + +from agent.handlers.echo_text import handle_echo_text + +__all__ = ["handle_echo_text"] diff --git a/inquiry-agent/agent/handlers/abnormal.py b/inquiry-agent/agent/handlers/abnormal.py new file mode 100644 index 0000000..1ac0b82 --- /dev/null +++ b/inquiry-agent/agent/handlers/abnormal.py @@ -0,0 +1,23 @@ +""" +意图:异常上报(骨架)。 + +正式:写主账异常时间线;不在此直连 TMS。 +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def handle_abnormal(*, sender_id: str, inquiry_no: str, reason: str = "") -> None: + """骨架:仅记日志。""" + if not sender_id: + return + logger.info( + "handler.abnormal stub sender=%s inquiry=%s reason=%s", + sender_id, + inquiry_no, + reason or "-", + ) diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py new file mode 100644 index 0000000..ce3fdf4 --- /dev/null +++ b/inquiry-agent/agent/handlers/card_action.py @@ -0,0 +1,32 @@ +""" +意图:模板卡 / 补问卡动作(骨架)。 + +正式:校验 clarification token 绑定的 waitVersion + 允许动作集合。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def handle_card_action( + *, + sender_id: str, + thread_id: str, + wait_version: int, + action: str, + payload: dict[str, Any] | None = None, +) -> None: + """骨架:仅记日志;无 sender_id 直接返回。""" + if not (sender_id or "").strip(): + return + logger.info( + "handler.card_action stub sender=%s thread=%s wait=%s action=%s", + sender_id, + thread_id, + wait_version, + action, + ) diff --git a/inquiry-agent/agent/handlers/close_deal.py b/inquiry-agent/agent/handlers/close_deal.py new file mode 100644 index 0000000..a7c3826 --- /dev/null +++ b/inquiry-agent/agent/handlers/close_deal.py @@ -0,0 +1,30 @@ +""" +意图:成交 / 未成交(骨架)。 + +正式:调主账闭环 API,带幂等键;图成功 ≠ 主账成功。 +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def handle_close_deal( + *, + sender_id: str, + inquiry_no: str, + outcome: str, +) -> None: + """ + outcome:won | lost 等;骨架不写主账。 + """ + if not sender_id or not inquiry_no: + return + logger.info( + "handler.close_deal stub sender=%s inquiry=%s outcome=%s", + sender_id, + inquiry_no, + outcome, + ) diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py new file mode 100644 index 0000000..133fe8c --- /dev/null +++ b/inquiry-agent/agent/handlers/continue_thread.py @@ -0,0 +1,24 @@ +""" +意图:继续本单 / Graph resume(骨架)。 + +正式:校验 thread_id + waitVersion 后 Worker 槽 resume;禁止在 HTTP 回调线程 invoke。 +""" + +from __future__ import annotations + +import logging + +from agent.channel.wecom.models import InboundMessage + +logger = logging.getLogger(__name__) + + +def handle_continue_thread(message: InboundMessage, *, thread_id: str = "") -> None: + """骨架:仅记日志。""" + if not message.has_sender(): + return + logger.info( + "handler.continue_thread stub sender=%s thread_id=%s", + message.sender_id, + thread_id or "-", + ) diff --git a/inquiry-agent/agent/handlers/echo_text.py b/inquiry-agent/agent/handlers/echo_text.py index b28f3fe..c16617c 100644 --- a/inquiry-agent/agent/handlers/echo_text.py +++ b/inquiry-agent/agent/handlers/echo_text.py @@ -9,8 +9,8 @@ from __future__ import annotations import logging +from typing import Any -from agent.channel.queue import MemoryMessageStore from agent.channel.wecom.models import InboundMessage logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def handle_echo_text( message: InboundMessage, - store: MemoryMessageStore, + store: Any, *, ytd_env: str, reply_template: str, diff --git a/inquiry-agent/agent/handlers/new_inquiry.py b/inquiry-agent/agent/handlers/new_inquiry.py new file mode 100644 index 0000000..0e35622 --- /dev/null +++ b/inquiry-agent/agent/handlers/new_inquiry.py @@ -0,0 +1,26 @@ +""" +意图:新开询价(骨架)。 + +正式:校验字段合同 → 主账建单准备 → Graph start_thread。 +本文件不调模型、不写六态。 +""" + +from __future__ import annotations + +import logging + +from agent.channel.wecom.models import InboundMessage + +logger = logging.getLogger(__name__) + + +def handle_new_inquiry(message: InboundMessage) -> None: + """骨架:仅记日志。须已有 sender_id。""" + if not message.has_sender(): + logger.warning("new_inquiry 拒绝:无 sender_id") + return + logger.info( + "handler.new_inquiry stub sender=%s msg_id=%s", + message.sender_id, + message.message_id, + ) diff --git a/inquiry-agent/agent/http_app.py b/inquiry-agent/agent/http_app.py index fe49b47..6cc8e69 100644 --- a/inquiry-agent/agent/http_app.py +++ b/inquiry-agent/agent/http_app.py @@ -13,6 +13,7 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI +from agent.channel.aibot import router as aibot_router from agent.channel.bridge import router as bridge_router from agent.channel.h5 import router as h5_router from agent.channel.runtime import start_channel_runtime @@ -71,6 +72,7 @@ def create_app() -> FastAPI: app.include_router(wecom_router) app.include_router(h5_router) app.include_router(bridge_router) + app.include_router(aibot_router) return app diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index d2e1b15..d21d451 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -1,5 +1,5 @@ """ -任务槽与认领循环:识别、LibreOffice、Archive 等。 +任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。 本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。 禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。 @@ -11,7 +11,18 @@ import logging import threading import time from dataclasses import dataclass, field -from typing import Callable +from typing import Callable, Optional + +from agent.config import Settings, get_settings +from agent.graph.runtime import GraphRuntime +from agent.jobs.archive import archive_stream, process_archive_stub +from agent.jobs.libreoffice import LibreOfficeConverter +from agent.jobs.pdf_job import pdf_stream, process_pdf_job +from agent.jobs.recognition import process_recognition_stub +from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream +from agent.llm import LlmGateway, LlmMode +from agent.redis_coord.runtime import RedisRuntime +from agent.routing import DeepSeekARouter logger = logging.getLogger(__name__) @@ -22,7 +33,6 @@ class SlotPool: 进程内有限槽线程池骨架。 用途:限制同类慢活并发(尤其 LibreOffice=1)。 - 为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。 """ name: str @@ -35,11 +45,7 @@ class SlotPool: self._sem = threading.BoundedSemaphore(self.size) def run(self, fn: Callable[[], None], *, label: str = "") -> None: - """ - 占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。 - - 副作用:执行 fn;不写库(骨架)。 - """ + """占用一个槽执行 fn;勿在 HTTP 回调线程调用。""" acquired = self._sem.acquire(timeout=30) if not acquired: logger.warning("槽超时未获取 name=%s label=%s", self.name, label) @@ -53,20 +59,33 @@ class SlotPool: @dataclass class WorkerRuntime: """ - Worker 运行时:持有分槽与 inbox/outbox 占位线程。 + Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / LibreOffice 循环。 - 启动时机:worker_app main;停止:进程退出或 stop()。 - 禁止:把所有任务丢进 LibreOffice 槽。 + 启动时机:worker_app main。 """ libreoffice: SlotPool http_jobs: SlotPool + settings: Settings + graph: Optional[GraphRuntime] = None + redis: Optional[RedisRuntime] = None + llm: Optional[LlmGateway] = None _stop: threading.Event = field(default_factory=threading.Event) _threads: list[threading.Thread] = field(default_factory=list) @classmethod - def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime": - """按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。""" + def from_settings( + cls, + libreoffice_slots: int, + http_job_slots: int, + *, + graph: Optional[GraphRuntime] = None, + redis: Optional[RedisRuntime] = None, + llm: Optional[LlmGateway] = None, + settings: Optional[Settings] = None, + ) -> "WorkerRuntime": + """按配置创建槽;强制 LibreOffice 槽为 1。""" + cfg = settings or get_settings() lo = 1 if libreoffice_slots != 1 else libreoffice_slots if libreoffice_slots != 1: logger.warning( @@ -76,47 +95,178 @@ class WorkerRuntime: return cls( libreoffice=SlotPool("libreoffice", lo), http_jobs=SlotPool("http_jobs", max(2, http_job_slots)), + settings=cfg, + graph=graph, + redis=redis, + llm=llm, ) def start(self) -> None: - """ - 启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。 - - 副作用:创建守护线程;不连 Redis(骨架)。 - """ + """启动分槽消费循环。""" self._stop.clear() self._threads = [ - threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True), - threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True), - threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True), + threading.Thread(target=self._wake_loop, name="wake-loop", daemon=True), + threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True), + threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True), + threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True), + threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True), ] for t in self._threads: t.start() + lo = LibreOfficeConverter.from_settings(self.settings) logger.info( - "WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s", + "WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s soffice=%s", self.libreoffice.size, self.http_jobs.size, + "ok" if lo.available() else "missing", ) def stop(self) -> None: - """请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。""" self._stop.set() - def _inbox_loop(self) -> None: - """inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。""" + def _wake_loop(self) -> None: + """HTTP 类槽消费结构化 wake → Graph。""" + if not self.redis or not self.graph: + while not self._stop.is_set(): + time.sleep(2.0) + return + q = wake_stream(self.redis.client) + consumer = f"wake-{threading.get_ident()}" while not self._stop.is_set(): - time.sleep(2.0) - def _outbox_loop(self) -> None: - """outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。""" + def _job() -> None: + claim_and_apply_wake(queue=q, graph=self.graph, consumer=consumer) + + self.http_jobs.run(_job, label="wake") + time.sleep(0.2) + + def _llm_loop(self) -> None: + """ + HTTP 类槽消费 LLM Stream。 + + ordered_actions:走 DeepSeekARouter(默认 stub;LLM_ALLOW_NETWORK 才可能真网)。 + 其它 mode:骨架 stub。 + """ + if not self.redis or not self.llm: + while not self._stop.is_set(): + time.sleep(2.0) + return + stream = self.redis.llm_stream + stream.ensure_group() + consumer = f"llm-{threading.get_ident()}" + router = DeepSeekARouter.from_settings( + self.settings, + allow_network=bool(self.settings.llm_allow_network), + ) while not self._stop.is_set(): - time.sleep(2.0) - def _http_slot_idle(self) -> None: - """证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。""" + def _job() -> None: + claimed = stream.claim(consumer=consumer, count=1, block_ms=200) + if not claimed: + return + entry_id, data = claimed[0] + mode = str(data.get("mode") or "ordered_actions") + payload = dict(data.get("payload") or {}) + try: + if mode == LlmMode.ORDERED_ACTIONS.value: + decision = router.route( + text=str(payload.get("text") or ""), + sender_id=str(payload.get("sender_id") or ""), + ) + logger.info( + "llm 槽 A 路由 id=%s intent=%s", + entry_id, + decision.intent, + ) + else: + result = self.llm.invoke_stub(mode=mode, payload=payload) + logger.info( + "llm 槽处理 id=%s mode=%s stub=%s", + entry_id, + mode, + result.get("stub"), + ) + finally: + stream.ack(entry_id) + + self.http_jobs.run(_job, label="llm") + time.sleep(0.2) + + def _recognition_loop(self) -> None: + """HTTP 类槽消费识别 Stream。""" + if not self.redis: + while not self._stop.is_set(): + time.sleep(2.0) + return + stream = self.redis.recognition_stream + stream.ensure_group() + consumer = f"recog-{threading.get_ident()}" while not self._stop.is_set(): - def _noop() -> None: - time.sleep(0.05) - self.http_jobs.run(_noop, label="idle-ping") - time.sleep(5.0) + def _job() -> None: + claimed = stream.claim(consumer=consumer, count=1, block_ms=200) + if not claimed: + return + entry_id, data = claimed[0] + try: + process_recognition_stub(data) + finally: + stream.ack(entry_id) + + self.http_jobs.run(_job, label="recognition") + time.sleep(0.2) + + def _archive_loop(self) -> None: + """HTTP 类槽消费存档 Stream(与 soffice 隔离)。""" + if not self.redis: + while not self._stop.is_set(): + time.sleep(2.0) + return + stream = archive_stream(self.redis) + stream.ensure_group() + consumer = f"arch-{threading.get_ident()}" + while not self._stop.is_set(): + + def _job() -> None: + claimed = stream.claim(consumer=consumer, count=1, block_ms=200) + if not claimed: + return + entry_id, data = claimed[0] + try: + process_archive_stub(data) + finally: + stream.ack(entry_id) + + self.http_jobs.run(_job, label="archive") + time.sleep(0.2) + + def _pdf_loop(self) -> None: + """仅 LibreOffice 单槽消费 PDF Stream。""" + if not self.redis: + while not self._stop.is_set(): + time.sleep(2.0) + return + stream = pdf_stream(self.redis) + stream.ensure_group() + converter = LibreOfficeConverter.from_settings(self.settings) + consumer = f"pdf-{threading.get_ident()}" + while not self._stop.is_set(): + + def _job() -> None: + claimed = stream.claim(consumer=consumer, count=1, block_ms=200) + if not claimed: + return + entry_id, data = claimed[0] + try: + result = process_pdf_job(data, converter=converter) + logger.info( + "pdf 槽 id=%s ok=%s err=%s", + entry_id, + result.ok, + result.error or "-", + ) + finally: + stream.ack(entry_id) + + self.libreoffice.run(_job, label="pdf") + time.sleep(0.3) diff --git a/inquiry-agent/agent/jobs/archive.py b/inquiry-agent/agent/jobs/archive.py new file mode 100644 index 0000000..cc22750 --- /dev/null +++ b/inquiry-agent/agent/jobs/archive.py @@ -0,0 +1,62 @@ +""" +微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。 + +本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。 +禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.redis_coord import get_redis_runtime, try_acquire_idempotency +from agent.redis_coord.keys import STREAM_ARCHIVE +from agent.redis_coord.runtime import RedisRuntime +from agent.redis_coord.stream_queue import StreamQueue + +logger = logging.getLogger(__name__) + + +@dataclass +class ArchiveEnqueueResult: + accepted: bool + entry_id: str = "" + reason: str = "" + + +def archive_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue: + """存档 Stream(懒创建消费组由调用方 ensure)。""" + rt = redis or get_redis_runtime() + return StreamQueue(client=rt.client, stream_suffix=STREAM_ARCHIVE) + + +def enqueue_archive( + *, + payload: dict[str, Any], + idempotency_key: str = "", + redis: Optional[RedisRuntime] = None, +) -> ArchiveEnqueueResult: + """存档任务入队(快回)。""" + rt = redis or get_redis_runtime() + q = archive_stream(rt) + q.ensure_group() + if idempotency_key: + ok = try_acquire_idempotency( + rt.client, + scope="archive", + idempotency_key=idempotency_key, + ttl_seconds=600, + ) + if not ok: + return ArchiveEnqueueResult(accepted=False, reason="duplicate_idempotency_key") + entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key) + logger.info("archive.enqueue id=%s", entry_id) + return ArchiveEnqueueResult(accepted=True, entry_id=entry_id) + + +def process_archive_stub(data: dict[str, Any]) -> dict[str, Any]: + """存档占位:不访问微盛、不写对象存储。""" + logger.info("archive.stub keys=%s", sorted(data.keys())) + return {"ok": True, "stub": True, "note": "存档壳:未下载未登记"} diff --git a/inquiry-agent/agent/jobs/dead_letter.py b/inquiry-agent/agent/jobs/dead_letter.py new file mode 100644 index 0000000..1087538 --- /dev/null +++ b/inquiry-agent/agent/jobs/dead_letter.py @@ -0,0 +1,146 @@ +""" +死信壳:失败任务落库 / 内存,不含自动重放业务。 + +本文件职责:enqueue_dead_letter;正式重放由运维/后续模块显式触发。 +禁止:把死信成功当成业务成功。 +""" + +from __future__ import annotations + +import json +import logging +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any, Optional + +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + + +@dataclass +class DeadLetterRecord: + id: str + source: str + source_id: str = "" + job_type: str = "" + payload: dict[str, Any] = field(default_factory=dict) + error: str = "" + attempts: int = 0 + + +class MemoryDeadLetterStore: + """内存死信壳。""" + + def __init__(self) -> None: + self._rows: list[DeadLetterRecord] = [] + self._lock = threading.Lock() + + def enqueue(self, record: DeadLetterRecord) -> str: + with self._lock: + if not record.id: + record.id = str(uuid.uuid4()) + self._rows.append(record) + logger.info( + "dead_letter.memory source=%s type=%s id=%s", + record.source, + record.job_type, + record.id, + ) + return record.id + + def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]: + with self._lock: + return list(reversed(self._rows[-limit:])) + + +class PostgresDeadLetterStore: + """PG agent_dead_letter 壳(须 002 迁移)。""" + + def __init__(self, dsn: str) -> None: + assert_safe_database(dsn) + self._dsn = dsn + + def enqueue(self, record: DeadLetterRecord) -> str: + did = record.id or str(uuid.uuid4()) + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO agent_dead_letter ( + id, source, source_id, job_type, payload, error, attempts + ) VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s) + """, + ( + did, + record.source, + record.source_id, + record.job_type, + json.dumps(record.payload, ensure_ascii=False), + record.error, + record.attempts, + ), + ) + conn.commit() + logger.info("dead_letter.pg id=%s source=%s", did, record.source) + return did + + def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]: + with connect_psycopg(self._dsn) as conn: + rows = conn.execute( + """ + SELECT id, source, source_id, job_type, payload, error, attempts + FROM agent_dead_letter + ORDER BY created_at DESC + LIMIT %s + """, + (limit,), + ).fetchall() + return [ + DeadLetterRecord( + id=str(r[0]), + source=str(r[1]), + source_id=str(r[2] or ""), + job_type=str(r[3] or ""), + payload=dict(r[4] or {}), + error=str(r[5] or ""), + attempts=int(r[6] or 0), + ) + for r in rows + ] + + +def create_dead_letter_store(*, dsn: str = "", backend: str = "auto"): + """工厂。""" + if backend == "memory" or not dsn: + return MemoryDeadLetterStore() + return PostgresDeadLetterStore(dsn) + + +def enqueue_dead_letter( + *, + source: str, + error: str, + job_type: str = "", + source_id: str = "", + payload: Optional[dict[str, Any]] = None, + attempts: int = 0, + store=None, +) -> str: + """ + 统一入死信入口(框架)。 + + store 为空则用内存临时店(调用方应注入进程级单例)。 + """ + st = store or MemoryDeadLetterStore() + return st.enqueue( + DeadLetterRecord( + id=str(uuid.uuid4()), + source=source, + source_id=source_id, + job_type=job_type, + payload=dict(payload or {}), + error=error, + attempts=attempts, + ) + ) diff --git a/inquiry-agent/agent/jobs/libreoffice.py b/inquiry-agent/agent/jobs/libreoffice.py new file mode 100644 index 0000000..073a120 --- /dev/null +++ b/inquiry-agent/agent/jobs/libreoffice.py @@ -0,0 +1,102 @@ +""" +LibreOffice Excel→PDF 转换壳。 + +本文件职责:探测 soffice、在「调用方已占用 LibreOffice 单槽」的前提下同步转 PDF。 +禁止:在企微回调 / H5 线程调用;禁止本进程同时起两台 soffice(由 SlotPool size=1 保证)。 +""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +@dataclass +class LibreOfficeResult: + """转换结果;ok=False 时 error 说明原因,不抛到 Worker 主循环。""" + + ok: bool + pdf_path: str = "" + error: str = "" + returncode: int = 0 + + +@dataclass +class LibreOfficeConverter: + """ + soffice 无界面转换器。 + + 调用约定:仅从 WorkerRuntime.libreoffice 槽内进入 convert()。 + """ + + settings: Settings + + @classmethod + def from_settings(cls, settings: Optional[Settings] = None) -> "LibreOfficeConverter": + return cls(settings=settings or get_settings()) + + def executable(self) -> Path: + return Path(self.settings.libreoffice_path) + + def available(self) -> bool: + """本机是否存在配置的 soffice 可执行文件。""" + return self.executable().is_file() + + def convert_xlsx_to_pdf(self, xlsx_path: str | Path) -> LibreOfficeResult: + """ + 将 Excel 转为同目录 PDF。 + + 副作用:启动 soffice 子进程(超时见 settings);成功写 PDF 文件。 + 线程:必须已在 LibreOffice 单槽内。 + """ + source = Path(xlsx_path).resolve() + if not source.is_file(): + return LibreOfficeResult(ok=False, error="source_not_found") + exe = self.executable() + if not exe.is_file(): + return LibreOfficeResult(ok=False, error="libreoffice_not_installed") + out_dir = source.parent + target = source.with_suffix(".pdf") + try: + completed = subprocess.run( + [ + str(exe), + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(out_dir), + str(source), + ], + capture_output=True, + timeout=float(self.settings.libreoffice_timeout_seconds), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("LibreOffice 执行失败: %s", exc) + return LibreOfficeResult(ok=False, error=f"exec_failed:{exc}") + if completed.returncode != 0 or not target.is_file(): + stderr = (completed.stderr or b"")[:400] + logger.warning( + "LibreOffice 未生成 PDF rc=%s stderr=%s", + completed.returncode, + stderr, + ) + return LibreOfficeResult( + ok=False, + error="pdf_not_generated", + returncode=int(completed.returncode), + ) + head = target.read_bytes()[:5] + if head != b"%PDF-": + target.unlink(missing_ok=True) + return LibreOfficeResult(ok=False, error="pdf_header_invalid") + logger.info("LibreOffice 转 PDF 成功 path=%s", target) + return LibreOfficeResult(ok=True, pdf_path=str(target), returncode=0) diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py new file mode 100644 index 0000000..429e930 --- /dev/null +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -0,0 +1,80 @@ +""" +报价 PDF 任务壳:入队后由 LibreOffice **单槽**消费。 + +本文件职责:enqueue_pdf_job / process 调用 LibreOfficeConverter。 +禁止:在 http_jobs 槽跑 soffice;禁止回调线程转换。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult +from agent.redis_coord import get_redis_runtime, try_acquire_idempotency +from agent.redis_coord.keys import STREAM_PDF +from agent.redis_coord.runtime import RedisRuntime +from agent.redis_coord.stream_queue import StreamQueue + +logger = logging.getLogger(__name__) + + +@dataclass +class PdfEnqueueResult: + accepted: bool + entry_id: str = "" + reason: str = "" + + +def pdf_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue: + rt = redis or get_redis_runtime() + return StreamQueue(client=rt.client, stream_suffix=STREAM_PDF) + + +def enqueue_pdf_job( + *, + xlsx_path: str, + inquiry_no: str = "", + quote_version: int = 0, + idempotency_key: str = "", + redis: Optional[RedisRuntime] = None, +) -> PdfEnqueueResult: + """Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。""" + rt = redis or get_redis_runtime() + q = pdf_stream(rt) + q.ensure_group() + if idempotency_key: + ok = try_acquire_idempotency( + rt.client, + scope="pdf", + idempotency_key=idempotency_key, + ttl_seconds=600, + ) + if not ok: + return PdfEnqueueResult(accepted=False, reason="duplicate_idempotency_key") + payload = { + "xlsx_path": xlsx_path, + "inquiry_no": inquiry_no, + "quote_version": quote_version, + } + entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key) + logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-") + return PdfEnqueueResult(accepted=True, entry_id=entry_id) + + +def process_pdf_job( + data: dict[str, Any], + *, + converter: Optional[LibreOfficeConverter] = None, +) -> LibreOfficeResult: + """ + 处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。 + + 缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。 + """ + path = str(data.get("xlsx_path") or "") + conv = converter or LibreOfficeConverter.from_settings() + if not path: + return LibreOfficeResult(ok=False, error="missing_xlsx_path") + return conv.convert_xlsx_to_pdf(path) diff --git a/inquiry-agent/agent/jobs/pg_job.py b/inquiry-agent/agent/jobs/pg_job.py new file mode 100644 index 0000000..cb35476 --- /dev/null +++ b/inquiry-agent/agent/jobs/pg_job.py @@ -0,0 +1,267 @@ +""" +PG agent_job 认领壳(与 Redis Stream 二选一真相时用)。 + +本文件职责:enqueue / claim / complete / requeue_expired;不含业务处理体。 +禁止:一把大锁串行所有工单;禁止 Redis wecom:worker:singleton。 +""" + +from __future__ import annotations + +import json +import logging +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class JobRecord: + """一条任务行。""" + + id: str + job_type: str + payload: dict[str, Any] = field(default_factory=dict) + state: str = "pending" + idempotency_key: str = "" + attempts: int = 0 + last_error: str = "" + + +class MemoryJobStore: + """内存任务认领壳。""" + + def __init__(self, *, claim_timeout_sec: float = 180.0) -> None: + self._rows: dict[str, JobRecord] = {} + self._claimed_at: dict[str, float] = {} + self._lock = threading.Lock() + self._claim_timeout = claim_timeout_sec + + def enqueue( + self, + *, + job_type: str, + payload: dict[str, Any], + idempotency_key: str = "", + ) -> tuple[bool, str]: + with self._lock: + if idempotency_key: + for j in self._rows.values(): + if j.job_type == job_type and j.idempotency_key == idempotency_key: + return False, "duplicate_idempotency_key" + job_id = str(uuid.uuid4()) + self._rows[job_id] = JobRecord( + id=job_id, + job_type=job_type, + payload=dict(payload), + idempotency_key=idempotency_key, + ) + return True, job_id + + def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]: + import time + + now = time.time() + with self._lock: + for jid, job in self._rows.items(): + if job_type and job.job_type != job_type: + continue + if job.state == "pending" or ( + job.state == "claimed" + and now - self._claimed_at.get(jid, 0) > self._claim_timeout + ): + job.state = "claimed" + job.attempts += 1 + self._claimed_at[jid] = now + return JobRecord( + id=job.id, + job_type=job.job_type, + payload=dict(job.payload), + state=job.state, + idempotency_key=job.idempotency_key, + attempts=job.attempts, + last_error=job.last_error, + ) + return None + + def complete(self, job_id: str, *, error: str = "", result: Optional[dict] = None) -> None: + with self._lock: + job = self._rows.get(job_id) + if not job: + return + job.state = "failed" if error else "done" + job.last_error = error + logger.info("job.memory.complete id=%s state=%s", job_id, job.state) + + +class PostgresJobStore: + """PG agent_job 认领壳。""" + + def __init__(self, dsn: str, *, claim_timeout_sec: float = 180.0) -> None: + assert_safe_database(dsn) + self._dsn = dsn + self._claim_timeout = int(claim_timeout_sec) + + def enqueue( + self, + *, + job_type: str, + payload: dict[str, Any], + idempotency_key: str = "", + ) -> tuple[bool, str]: + job_id = str(uuid.uuid4()) + try: + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO agent_job (id, job_type, idempotency_key, payload, state) + VALUES (%s, %s, %s, %s::jsonb, 'pending') + """, + ( + job_id, + job_type, + idempotency_key, + json.dumps(payload, ensure_ascii=False), + ), + ) + conn.commit() + return True, job_id + except Exception as exc: # noqa: BLE001 + if "unique" in str(exc).lower() or "duplicate" in str(exc).lower(): + return False, "duplicate_idempotency_key" + raise + + def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]: + type_filter = "AND job_type = %s" if job_type else "" + params: list[Any] = [self._claim_timeout, worker_id] + if job_type: + params.append(job_type) + sql = f""" + WITH cte AS ( + SELECT id FROM agent_job + WHERE ( + state = 'pending' + OR (state = 'claimed' AND claimed_at < NOW() - (%s || ' seconds')::interval) + ) + {type_filter} + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE agent_job j SET + state = 'claimed', + claimed_at = NOW(), + claimed_by = %s, + attempts = j.attempts + 1, + updated_at = NOW() + FROM cte WHERE j.id = cte.id + RETURNING j.id, j.job_type, j.payload, j.state, j.idempotency_key, j.attempts, j.last_error + """ + # 参数顺序:timeout, [job_type], worker_id — 上面拼法易错,改用固定两段 + with connect_psycopg(self._dsn) as conn: + if job_type: + row = conn.execute( + """ + WITH cte AS ( + SELECT id FROM agent_job + WHERE job_type = %s AND ( + state = 'pending' + OR (state = 'claimed' + AND claimed_at < NOW() - (%s || ' seconds')::interval) + ) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE agent_job j SET + state = 'claimed', + claimed_at = NOW(), + claimed_by = %s, + attempts = j.attempts + 1, + updated_at = NOW() + FROM cte WHERE j.id = cte.id + RETURNING j.id, j.job_type, j.payload, j.state, + j.idempotency_key, j.attempts, j.last_error + """, + (job_type, str(self._claim_timeout), worker_id), + ).fetchone() + else: + row = conn.execute( + """ + WITH cte AS ( + SELECT id FROM agent_job + WHERE state = 'pending' + OR (state = 'claimed' + AND claimed_at < NOW() - (%s || ' seconds')::interval) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE agent_job j SET + state = 'claimed', + claimed_at = NOW(), + claimed_by = %s, + attempts = j.attempts + 1, + updated_at = NOW() + FROM cte WHERE j.id = cte.id + RETURNING j.id, j.job_type, j.payload, j.state, + j.idempotency_key, j.attempts, j.last_error + """, + (str(self._claim_timeout), worker_id), + ).fetchone() + conn.commit() + if not row: + return None + return JobRecord( + id=str(row[0]), + job_type=str(row[1]), + payload=dict(row[2] or {}), + state=str(row[3]), + idempotency_key=str(row[4] or ""), + attempts=int(row[5] or 0), + last_error=str(row[6] or ""), + ) + + def complete( + self, + job_id: str, + *, + error: str = "", + result: Optional[dict[str, Any]] = None, + ) -> None: + state = "failed" if error else "done" + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + UPDATE agent_job SET + state = %s, + last_error = %s, + result = %s::jsonb, + updated_at = NOW() + WHERE id = %s + """, + ( + state, + error, + json.dumps(result or {}, ensure_ascii=False), + job_id, + ), + ) + conn.commit() + logger.info("job.pg.complete id=%s state=%s", job_id, state) + + +def create_job_store(*, dsn: str = "", backend: str = "auto", claim_timeout_sec: float = 180.0): + """工厂:Memory 或 PG。""" + if backend == "memory" or not dsn: + return MemoryJobStore(claim_timeout_sec=claim_timeout_sec) + return PostgresJobStore(dsn, claim_timeout_sec=claim_timeout_sec) diff --git a/inquiry-agent/agent/jobs/recognition.py b/inquiry-agent/agent/jobs/recognition.py new file mode 100644 index 0000000..dd3178d --- /dev/null +++ b/inquiry-agent/agent/jobs/recognition.py @@ -0,0 +1,79 @@ +""" +识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。 + +本文件职责:enqueue / process_stub;正式识别(附件/OCR/字段)后续按 payload.kind 分支。 +禁止:回调线程同步等识别完成;禁止本模块写主账六态。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.redis_coord import get_redis_runtime, try_acquire_idempotency +from agent.redis_coord.runtime import RedisRuntime +from agent.redis_coord.stream_queue import StreamQueue + +logger = logging.getLogger(__name__) + + +@dataclass +class RecognitionEnqueueResult: + accepted: bool + entry_id: str = "" + reason: str = "" + + +def recognition_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue: + """返回识别 Stream 句柄。""" + rt = redis or get_redis_runtime() + return rt.recognition_stream + + +def enqueue_recognition( + *, + kind: str, + payload: dict[str, Any], + idempotency_key: str = "", + redis: Optional[RedisRuntime] = None, +) -> RecognitionEnqueueResult: + """ + 识别任务入队(快回)。 + + kind 例:attachment_text / vision / sheet_map。 + """ + rt = redis or get_redis_runtime() + if idempotency_key: + ok = try_acquire_idempotency( + rt.client, + scope="recognition", + idempotency_key=idempotency_key, + ttl_seconds=600, + ) + if not ok: + return RecognitionEnqueueResult(accepted=False, reason="duplicate_idempotency_key") + body = {"kind": kind, "payload": payload} + entry_id = rt.recognition_stream.enqueue( + payload=body, + idempotency_key=idempotency_key, + ) + logger.info("recognition.enqueue kind=%s id=%s", kind, entry_id) + return RecognitionEnqueueResult(accepted=True, entry_id=entry_id) + + +def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]: + """ + 识别占位:回显 kind,不读附件、不调模型。 + + Worker 槽调用;正式实现替换为本函数旁的真实处理器。 + """ + kind = str(data.get("kind") or "unknown") + payload = data.get("payload") or {} + logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else []) + return { + "ok": True, + "stub": True, + "kind": kind, + "note": "识别壳:未跑真实抽取", + } diff --git a/inquiry-agent/agent/jobs/wake_consumer.py b/inquiry-agent/agent/jobs/wake_consumer.py new file mode 100644 index 0000000..f06f67a --- /dev/null +++ b/inquiry-agent/agent/jobs/wake_consumer.py @@ -0,0 +1,82 @@ +""" +Worker 侧:消费主账/文件完成等结构化 wake。 + +本文件职责:Redis Stream 入队/认领;在 HTTP 类槽内调 GraphRuntime.wake_from_worker。 +禁止:在企微回调线程调用;禁止用户文字冒充本路径。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from agent.graph.runtime import GraphRuntime +from agent.graph.wake import WorkerWakePayload +from agent.redis_coord.client import RedisClient +from agent.redis_coord.stream_queue import StreamQueue + +logger = logging.getLogger(__name__) + +STREAM_WAKE = "stream:wake" +WAKE_GROUP = "inquiry-wake-workers" + + +def wake_stream(client: RedisClient) -> StreamQueue: + """结构化 wake 专用 Stream。""" + q = StreamQueue(client=client, stream_suffix=STREAM_WAKE, group=WAKE_GROUP) + q.ensure_group() + return q + + +def enqueue_wake(client: RedisClient, payload: WorkerWakePayload) -> str: + """ + 入队 wake(HTTP bridge 快回路径)。 + + 副作用:XADD;不跑 Graph。 + """ + payload.validate() + q = wake_stream(client) + return q.enqueue( + payload={ + "thread_id": payload.thread_id, + "inquiry_no": payload.inquiry_no, + "quote_version": int(payload.quote_version), + "wait_version": int(payload.wait_version), + "kind": payload.kind, + "detail": payload.detail or {}, + }, + idempotency_key=f"wake:{payload.thread_id}:{payload.wait_version}:{payload.kind}", + ) + + +def claim_and_apply_wake( + *, + queue: StreamQueue, + graph: GraphRuntime, + consumer: str, +) -> bool: + """ + 认领 1 条并 wake Graph。成功/失败均 ack(骨架防堵死;正式应进死信)。 + + 返回:是否领到任务。 + """ + claimed = queue.claim(consumer=consumer, count=1, block_ms=200) + if not claimed: + return False + entry_id, data = claimed[0] + try: + wake = WorkerWakePayload( + thread_id=str(data.get("thread_id") or ""), + inquiry_no=str(data.get("inquiry_no") or ""), + quote_version=int(data.get("quote_version") or 0), + wait_version=int(data.get("wait_version") or 0), + kind=str(data.get("kind") or ""), + detail=dict(data.get("detail") or {}), + ) + graph.wake_from_worker(wake) + logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind) + except Exception: # noqa: BLE001 + logger.exception("wake 处理失败 id=%s", entry_id) + finally: + queue.ack(entry_id) + return True diff --git a/inquiry-agent/agent/ledger/__init__.py b/inquiry-agent/agent/ledger/__init__.py index d492edb..53bbd86 100644 --- a/inquiry-agent/agent/ledger/__init__.py +++ b/inquiry-agent/agent/ledger/__init__.py @@ -2,25 +2,152 @@ 主账 HTTP 客户端:只打主账 API,不直连 TMS / MySQL。 正式写操作必须带幂等键;Graph 成功 ≠ 主账成功。 -骨架:仅占位客户端。 +本文件提供可联调的 ping / JSON 请求骨架;不做六态业务分支。 """ from __future__ import annotations -from dataclasses import dataclass +import logging +from dataclasses import dataclass, field +from typing import Any, Optional + +import httpx + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +@dataclass +class LedgerResponse: + """主账一次 HTTP 调用的结果摘要(不含密钥)。""" + + ok: bool + status_code: int + data: Any = None + error: str = "" @dataclass class LedgerClient: """ - 主账客户端骨架。 + 主账客户端。 - 参数:base_url 来自 BIZ_API_BASE_URL。 - 禁止:在本类拼 TMS 签名或写工单 SQL。 + base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。 + 超时:默认 10s,禁止无限阻塞 Worker 槽。 + 禁止:在本类拼 TMS HMAC 或写工单 SQL。 """ base_url: str + timeout_seconds: float = 10.0 + service_token: str = "" + _client: Optional[httpx.Client] = field(default=None, repr=False) + + @classmethod + def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient": + """从环境组装客户端。""" + cfg = settings or get_settings() + return cls( + base_url=(cfg.biz_api_base_url or "").rstrip("/"), + timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10), + service_token=(getattr(cfg, "biz_api_token", None) or ""), + ) + + def _http(self) -> httpx.Client: + if self._client is None: + headers = {"Accept": "application/json"} + if self.service_token: + headers["X-Access-Token"] = self.service_token + self._client = httpx.Client( + base_url=self.base_url, + timeout=self.timeout_seconds, + headers=headers, + ) + return self._client + + def close(self) -> None: + """关闭连接池。""" + if self._client is not None: + self._client.close() + self._client = None def health_url(self) -> str: - """返回主账健康检查 URL,供后续联调;骨架不发请求。""" - return f"{self.base_url.rstrip('/')}/health" + """健康检查相对路径候选的完整 URL(日志用)。""" + return f"{self.base_url}/" + + def ping(self) -> LedgerResponse: + """ + 探测主账是否可达。 + + 尝试 GET / 或常见健康路径;4xx/5xx 记入 error,不抛给回调线程。 + 副作用:出站 HTTP。 + """ + if not self.base_url: + return LedgerResponse(ok=False, status_code=0, error="BIZ_API_BASE_URL 未配置") + paths = ["/", "/sys/randomImage/ping-check"] + last: LedgerResponse = LedgerResponse(ok=False, status_code=0, error="no_attempt") + for path in paths: + try: + resp = self._http().get(path) + last = LedgerResponse( + ok=resp.status_code < 500, + status_code=resp.status_code, + data={"path": path}, + ) + if resp.status_code < 500: + logger.info( + "ledger.ping ok status=%s path=%s", + resp.status_code, + path, + ) + return last + except httpx.HTTPError as exc: + last = LedgerResponse(ok=False, status_code=0, error=str(exc)) + logger.warning("ledger.ping 失败 path=%s err=%s", path, exc) + return last + + def request_json( + self, + method: str, + path: str, + *, + json_body: Optional[dict[str, Any]] = None, + idempotency_key: str = "", + ) -> LedgerResponse: + """ + 通用 JSON 请求骨架。 + + 写操作应传入 idempotency_key(写入头 X-Idempotency-Key)。 + 不做业务语义解析;调用方根据主账合同处理 result。 + """ + headers: dict[str, str] = {} + if idempotency_key: + headers["X-Idempotency-Key"] = idempotency_key + try: + resp = self._http().request( + method.upper(), + path, + json=json_body, + headers=headers, + ) + data: Any + try: + data = resp.json() + except ValueError: + data = {"text": resp.text[:500]} + ok = 200 <= resp.status_code < 300 + if not ok: + logger.warning( + "ledger.request 非成功 status=%s path=%s", + resp.status_code, + path, + ) + return LedgerResponse(ok=ok, status_code=resp.status_code, data=data) + except httpx.HTTPError as exc: + logger.warning("ledger.request 异常 path=%s err=%s", path, exc) + return LedgerResponse(ok=False, status_code=0, error=str(exc)) + + +from agent.ledger.api_shell import LedgerApiShell # noqa: E402 + +__all__ = ["LedgerClient", "LedgerResponse", "LedgerApiShell"] diff --git a/inquiry-agent/agent/ledger/api_shell.py b/inquiry-agent/agent/ledger/api_shell.py new file mode 100644 index 0000000..758d806 --- /dev/null +++ b/inquiry-agent/agent/ledger/api_shell.py @@ -0,0 +1,111 @@ +""" +主账 API 方法签名壳。 + +本文件职责:列出智能体将调用的主账端点形状;默认返回 not_implemented,不假装成功。 +禁止:直连 TMS / MySQL;Graph 成功不能冒充本文件成功。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from agent.ledger import LedgerClient, LedgerResponse + +logger = logging.getLogger(__name__) + + +class LedgerApiShell: + """ + 主账业务 API 空壳(签名齐、实现空)。 + + 正式:各方法改走 request_json 打真实路径;路径以主账 OpenAPI 为准。 + """ + + def __init__(self, client: Optional[LedgerClient] = None) -> None: + self.client = client or LedgerClient.from_settings() + + def _nyi(self, name: str) -> LedgerResponse: + logger.info("ledger.api_shell.%s not_implemented", name) + return LedgerResponse( + ok=False, + status_code=501, + error=f"not_implemented:{name}", + data={"method": name}, + ) + + def create_ticket( + self, + *, + payload: dict[str, Any], + idempotency_key: str, + ) -> LedgerResponse: + """建单壳。""" + _ = (payload, idempotency_key) + return self._nyi("create_ticket") + + def transition_ticket( + self, + *, + inquiry_no: str, + to_status: str, + idempotency_key: str, + reason: str = "", + ) -> LedgerResponse: + """六态迁移壳。""" + _ = (inquiry_no, to_status, idempotency_key, reason) + return self._nyi("transition_ticket") + + def get_ticket(self, *, inquiry_no: str) -> LedgerResponse: + """查工单壳。""" + _ = inquiry_no + return self._nyi("get_ticket") + + def upsert_quote_version( + self, + *, + inquiry_no: str, + quote_version: int, + payload: dict[str, Any], + idempotency_key: str, + ) -> LedgerResponse: + """报价版本壳。""" + _ = (inquiry_no, quote_version, payload, idempotency_key) + return self._nyi("upsert_quote_version") + + def register_attachment( + self, + *, + inquiry_no: str, + object_key: str, + meta: dict[str, Any], + idempotency_key: str, + ) -> LedgerResponse: + """附件元数据登记壳。""" + _ = (inquiry_no, object_key, meta, idempotency_key) + return self._nyi("register_attachment") + + def get_staff_by_wecom_id(self, *, wecom_id: str) -> LedgerResponse: + """人员角色壳(按已有 wecom_id)。""" + _ = wecom_id + return self._nyi("get_staff_by_wecom_id") + + def list_quote_templates(self, *, enabled_only: bool = True) -> LedgerResponse: + """ + 报价模板列表壳。 + + 真相:后台上传/启用;智能体只读已启用项,再经对象存储取字节。 + """ + _ = enabled_only + return self._nyi("list_quote_templates") + + def wake_agent_ack( + self, + *, + inquiry_no: str, + quote_version: int, + wait_version: int, + ) -> LedgerResponse: + """主账侧唤醒回执壳(智能体被唤醒后可选回写)。""" + _ = (inquiry_no, quote_version, wait_version) + return self._nyi("wake_agent_ack") diff --git a/inquiry-agent/agent/llm/__init__.py b/inquiry-agent/agent/llm/__init__.py index 69efd07..7de968a 100644 --- a/inquiry-agent/agent/llm/__init__.py +++ b/inquiry-agent/agent/llm/__init__.py @@ -1,6 +1,20 @@ """ -LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。 +LLM 网关与队列:DeepSeek / 千问 mode、入队、认领(Worker 侧后续接)。 -禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。 -骨架:仅包占位。 +禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁;禁止回调线程等完整 LLM。 """ + +from agent.llm.gateway import LlmEnqueueResult, LlmGateway +from agent.llm.http_client import LlmHttpClient, LlmHttpResult +from agent.llm.mode_registry import invoke_mode_shell, list_mode_shells +from agent.llm.modes import LlmMode + +__all__ = [ + "LlmMode", + "LlmGateway", + "LlmEnqueueResult", + "LlmHttpClient", + "LlmHttpResult", + "invoke_mode_shell", + "list_mode_shells", +] diff --git a/inquiry-agent/agent/llm/gateway.py b/inquiry-agent/agent/llm/gateway.py new file mode 100644 index 0000000..5784353 --- /dev/null +++ b/inquiry-agent/agent/llm/gateway.py @@ -0,0 +1,127 @@ +""" +LLM 网关骨架:入队 Redis Stream,不在回调线程等完整结果。 + +本文件职责:校验 mode、短幂等、XADD;真实模型 HTTP 后续按 mode 实现。 +禁止:wait_result 堵在请求线程;禁止本包维护工单六态。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.config import Settings, get_settings +from agent.llm.modes import LlmMode +from agent.redis_coord import get_redis_runtime, try_acquire_idempotency +from agent.redis_coord.runtime import RedisRuntime + +logger = logging.getLogger(__name__) + + +@dataclass +class LlmEnqueueResult: + """入队结果。""" + + accepted: bool + entry_id: str = "" + reason: str = "" + + +@dataclass +class LlmGateway: + """ + LLM 网关。 + + 调用:HTTP/Handler 只 enqueue;Worker 槽 claim 后调 invoke_stub/未来真调用。 + """ + + settings: Settings + redis: RedisRuntime + + @classmethod + def from_settings(cls, settings: Optional[Settings] = None) -> "LlmGateway": + cfg = settings or get_settings() + return cls(settings=cfg, redis=get_redis_runtime(cfg)) + + def enqueue( + self, + *, + mode: LlmMode | str, + payload: dict[str, Any], + idempotency_key: str = "", + ) -> LlmEnqueueResult: + """ + 将任务写入 LLM Stream。 + + 重复 idempotency_key(TTL 内)返回 accepted=False。 + 副作用:可能写 Redis;不调外部模型。 + """ + mode_val = mode.value if isinstance(mode, LlmMode) else str(mode) + try: + LlmMode(mode_val) + except ValueError: + return LlmEnqueueResult(accepted=False, reason=f"unknown_mode:{mode_val}") + + if idempotency_key: + ok = try_acquire_idempotency( + self.redis.client, + scope=f"llm:{mode_val}", + idempotency_key=idempotency_key, + ttl_seconds=300, + ) + if not ok: + return LlmEnqueueResult(accepted=False, reason="duplicate_idempotency_key") + + body = {"mode": mode_val, "payload": payload} + entry_id = self.redis.llm_stream.enqueue( + payload=body, + idempotency_key=idempotency_key, + ) + logger.info("llm.enqueue mode=%s id=%s", mode_val, entry_id) + return LlmEnqueueResult(accepted=True, entry_id=entry_id) + + def invoke_stub(self, *, mode: LlmMode | str, payload: dict[str, Any]) -> dict[str, Any]: + """ + 模型调用占位:返回固定结构,不访问外网。 + + 正式实现按 mode 调 DeepSeek/千问;本方法仅供骨架与单测。 + """ + mode_val = mode.value if isinstance(mode, LlmMode) else str(mode) + if mode_val != LlmMode.ORDERED_ACTIONS.value: + from agent.llm.mode_registry import invoke_mode_shell + + return invoke_mode_shell(mode_val, payload=payload) + return { + "ok": True, + "stub": True, + "mode": mode_val, + "echo_keys": sorted(payload.keys()), + "note": "骨架不调用真实模型", + } + + def invoke( + self, + *, + mode: LlmMode | str, + messages: list[dict[str, Any]], + allow_network: bool = False, + ) -> dict[str, Any]: + """ + Worker 槽内调用模型。 + + allow_network=False:走 stub(默认,防误打费)。 + allow_network=True:走 LlmHttpClient(需已配 API Key)。 + """ + if not allow_network: + return self.invoke_stub(mode=mode, payload={"messages": len(messages)}) + from agent.llm.http_client import LlmHttpClient + + result = LlmHttpClient.from_settings(self.settings).chat(mode=mode, messages=messages) + return { + "ok": result.ok, + "stub": False, + "provider": result.provider, + "content": result.content, + "error": result.error, + } diff --git a/inquiry-agent/agent/llm/http_client.py b/inquiry-agent/agent/llm/http_client.py new file mode 100644 index 0000000..0c9b7bc --- /dev/null +++ b/inquiry-agent/agent/llm/http_client.py @@ -0,0 +1,156 @@ +""" +LLM 出站 HTTP 客户端(DeepSeek / 千问兼容 OpenAI Chat)。 + +本文件职责:带超时的 chat.completions 调用;缺 key / 未确认数据用途时明确失败。 +禁止:在企微回调线程同步长等;正式长调用走 Worker 槽 + 队列。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +import httpx + +from agent.config import Settings, get_settings +from agent.llm.modes import LlmMode + +logger = logging.getLogger(__name__) + + +@dataclass +class LlmHttpResult: + ok: bool + content: str = "" + raw: Optional[dict[str, Any]] = None + error: str = "" + provider: str = "" + tool_calls: list[dict[str, Any]] | None = None + + +@dataclass +class LlmHttpClient: + """ + 兼容 OpenAI 的 Chat Completions 客户端。 + + DeepSeek / 千问 DashScope compatible-mode 共用此壳。 + """ + + settings: Settings + timeout_seconds: float = 60.0 + + @classmethod + def from_settings(cls, settings: Optional[Settings] = None) -> "LlmHttpClient": + return cls(settings=settings or get_settings()) + + def _provider_for_mode(self, mode: LlmMode | str) -> tuple[str, str, str]: + """ + 返回 (provider, base_url, api_key)。 + + A/B 文本 → DeepSeek;附件/视觉 → 千问。 + """ + mode_val = mode.value if isinstance(mode, LlmMode) else str(mode) + visionish = mode_val in { + LlmMode.ATTACHMENT_TEXT.value, + LlmMode.VISION.value, + LlmMode.SHEET_MAP.value, + } + if visionish: + return ( + "qwen", + (self.settings.qwen_base_url or "").rstrip("/"), + self.settings.qwen_api_key or "", + ) + return ( + "deepseek", + (self.settings.deepseek_base_url or "").rstrip("/"), + self.settings.deepseek_api_key or "", + ) + + def _model_for(self, provider: str, mode: LlmMode | str) -> str: + mode_val = mode.value if isinstance(mode, LlmMode) else str(mode) + if provider == "qwen": + if mode_val == LlmMode.VISION.value: + return self.settings.qwen_vl_model or "qwen-vl-plus" + return self.settings.qwen_text_model or "qwen-plus" + return self.settings.deepseek_model or "deepseek-chat" + + def chat( + self, + *, + mode: LlmMode | str, + messages: list[dict[str, Any]], + temperature: float = 0.0, + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Any = None, + ) -> LlmHttpResult: + """ + 发起一次 chat.completions(可带 tools)。 + + 副作用:出站 HTTPS;超时由 timeout_seconds 限制。 + 未确认 LLM_DATA_USAGE_CONFIRMED 时拒绝调用(prompt/04)。 + """ + if not self.settings.llm_data_usage_confirmed: + return LlmHttpResult( + ok=False, + error="llm_data_usage_not_confirmed", + provider="none", + ) + provider, base, key = self._provider_for_mode(mode) + if not key or not base: + return LlmHttpResult( + ok=False, + error=f"{provider}_not_configured", + provider=provider, + ) + model = self._model_for(provider, mode) + url = f"{base}/chat/completions" + headers = { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + body: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + } + if tools: + body["tools"] = tools + if tool_choice is not None: + body["tool_choice"] = tool_choice + try: + with httpx.Client(timeout=self.timeout_seconds) as client: + resp = client.post(url, headers=headers, json=body) + data = resp.json() if resp.content else {} + if resp.status_code >= 400: + logger.warning( + "llm.http 失败 provider=%s status=%s", + provider, + resp.status_code, + ) + return LlmHttpResult( + ok=False, + error=f"http_{resp.status_code}", + raw=data if isinstance(data, dict) else None, + provider=provider, + ) + choices = (data or {}).get("choices") or [] + content = "" + tool_calls: list[dict[str, Any]] = [] + if choices: + msg = (choices[0].get("message") or {}) + content = str(msg.get("content") or "") + raw_calls = msg.get("tool_calls") or [] + if isinstance(raw_calls, list): + tool_calls = [c for c in raw_calls if isinstance(c, dict)] + return LlmHttpResult( + ok=True, + content=content, + raw=data if isinstance(data, dict) else None, + provider=provider, + tool_calls=tool_calls or None, + ) + except httpx.HTTPError as exc: + logger.warning("llm.http 异常 provider=%s err=%s", provider, exc) + return LlmHttpResult(ok=False, error=str(exc), provider=provider) diff --git a/inquiry-agent/agent/llm/mode_attachment_text.py b/inquiry-agent/agent/llm/mode_attachment_text.py new file mode 100644 index 0000000..0ff7e12 --- /dev/null +++ b/inquiry-agent/agent/llm/mode_attachment_text.py @@ -0,0 +1,30 @@ +""" +千问:附件文本 mode 壳。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.llm.modes import LlmMode + +MODE = LlmMode.ATTACHMENT_TEXT + + +def mode_meta() -> dict[str, Any]: + return { + "mode": MODE.value, + "provider": "qwen", + "purpose": "attachment_text", + "implemented": False, + } + + +def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + return { + "ok": False, + "stub": True, + "mode": MODE.value, + "error": "attachment_text_shell_not_implemented", + "echo_keys": sorted(payload.keys()), + } diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py new file mode 100644 index 0000000..5575b78 --- /dev/null +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -0,0 +1,35 @@ +""" +DeepSeek B:文本字段抽取 mode 壳。 + +本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。 +禁止:在本文件改主账六态。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.llm.modes import LlmMode + +MODE = LlmMode.EXTRACT_FIELDS + + +def mode_meta() -> dict[str, Any]: + """返回本 mode 的框架元数据(无 prompt 正文业务)。""" + return { + "mode": MODE.value, + "provider": "deepseek", + "purpose": "text_field_extract", + "implemented": False, + } + + +def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + """空壳:不调模型,返回未实现。""" + return { + "ok": False, + "stub": True, + "mode": MODE.value, + "error": "extract_fields_shell_not_implemented", + "echo_keys": sorted(payload.keys()), + } diff --git a/inquiry-agent/agent/llm/mode_group_quote.py b/inquiry-agent/agent/llm/mode_group_quote.py new file mode 100644 index 0000000..74a39e2 --- /dev/null +++ b/inquiry-agent/agent/llm/mode_group_quote.py @@ -0,0 +1,30 @@ +""" +群报价抽取 mode 壳。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.llm.modes import LlmMode + +MODE = LlmMode.GROUP_QUOTE + + +def mode_meta() -> dict[str, Any]: + return { + "mode": MODE.value, + "provider": "deepseek", + "purpose": "group_quote", + "implemented": False, + } + + +def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + return { + "ok": False, + "stub": True, + "mode": MODE.value, + "error": "group_quote_shell_not_implemented", + "echo_keys": sorted(payload.keys()), + } diff --git a/inquiry-agent/agent/llm/mode_registry.py b/inquiry-agent/agent/llm/mode_registry.py new file mode 100644 index 0000000..af527bf --- /dev/null +++ b/inquiry-agent/agent/llm/mode_registry.py @@ -0,0 +1,34 @@ +""" +LLM mode 空壳注册表:按 mode 名取 invoke_shell。 + +本文件只做分发到各 mode_* 模块;ordered_actions 走 routing/DeepSeekA。 +""" + +from __future__ import annotations + +from typing import Any, Callable + +from agent.llm import mode_attachment_text, mode_extract_fields, mode_group_quote, mode_sheet_map, mode_vision +from agent.llm.modes import LlmMode + +_SHELLS: dict[str, Callable[..., dict[str, Any]]] = { + LlmMode.EXTRACT_FIELDS.value: mode_extract_fields.invoke_shell, + LlmMode.ATTACHMENT_TEXT.value: mode_attachment_text.invoke_shell, + LlmMode.VISION.value: mode_vision.invoke_shell, + LlmMode.GROUP_QUOTE.value: mode_group_quote.invoke_shell, + LlmMode.SHEET_MAP.value: mode_sheet_map.invoke_shell, +} + + +def invoke_mode_shell(mode: str | LlmMode, *, payload: dict[str, Any]) -> dict[str, Any]: + """调用对应 mode 空壳;未知 mode 返回错误结构。""" + key = mode.value if isinstance(mode, LlmMode) else str(mode) + fn = _SHELLS.get(key) + if fn is None: + return {"ok": False, "stub": True, "mode": key, "error": "unknown_or_unregistered_mode"} + return fn(payload=payload) + + +def list_mode_shells() -> list[str]: + """已注册空壳 mode 名。""" + return sorted(_SHELLS.keys()) diff --git a/inquiry-agent/agent/llm/mode_sheet_map.py b/inquiry-agent/agent/llm/mode_sheet_map.py new file mode 100644 index 0000000..6cfce7e --- /dev/null +++ b/inquiry-agent/agent/llm/mode_sheet_map.py @@ -0,0 +1,30 @@ +""" +报价工作表映射 mode 壳。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.llm.modes import LlmMode + +MODE = LlmMode.SHEET_MAP + + +def mode_meta() -> dict[str, Any]: + return { + "mode": MODE.value, + "provider": "qwen", + "purpose": "sheet_map", + "implemented": False, + } + + +def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + return { + "ok": False, + "stub": True, + "mode": MODE.value, + "error": "sheet_map_shell_not_implemented", + "echo_keys": sorted(payload.keys()), + } diff --git a/inquiry-agent/agent/llm/mode_vision.py b/inquiry-agent/agent/llm/mode_vision.py new file mode 100644 index 0000000..3e8958e --- /dev/null +++ b/inquiry-agent/agent/llm/mode_vision.py @@ -0,0 +1,30 @@ +""" +千问视觉:读图 mode 壳。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.llm.modes import LlmMode + +MODE = LlmMode.VISION + + +def mode_meta() -> dict[str, Any]: + return { + "mode": MODE.value, + "provider": "qwen", + "purpose": "vision", + "implemented": False, + } + + +def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: + return { + "ok": False, + "stub": True, + "mode": MODE.value, + "error": "vision_shell_not_implemented", + "echo_keys": sorted(payload.keys()), + } diff --git a/inquiry-agent/agent/llm/modes.py b/inquiry-agent/agent/llm/modes.py new file mode 100644 index 0000000..4d09724 --- /dev/null +++ b/inquiry-agent/agent/llm/modes.py @@ -0,0 +1,20 @@ +""" +LLM 模式名(对齐 prompt/06)。 + +本文件只声明枚举;具体 prompt/schema 后续按 mode 分文件,禁止堆进上帝模块。 +""" + +from __future__ import annotations + +from enum import Enum + + +class LlmMode(str, Enum): + """允许入队的 LLM 模式。""" + + ORDERED_ACTIONS = "ordered_actions" # DeepSeek A + EXTRACT_FIELDS = "extract_fields" # DeepSeek B + ATTACHMENT_TEXT = "attachment_text" # 千问读附件 + VISION = "vision" # 千问读图 + GROUP_QUOTE = "group_quote" # 群报价抽取 + SHEET_MAP = "sheet_map" # 报价工作表映射 diff --git a/inquiry-agent/agent/policy/__init__.py b/inquiry-agent/agent/policy/__init__.py new file mode 100644 index 0000000..a027922 --- /dev/null +++ b/inquiry-agent/agent/policy/__init__.py @@ -0,0 +1,29 @@ +""" +policy 包:跨 handler 的公共校验落点(空壳)。 + +本文件职责:占位;身份/Schema/权限等确定性规则后续放本包。 +禁止:自然语言语义;禁止把全部卡片逻辑堆进本包。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def policy_package_ready() -> bool: + """框架探测:包可导入即 True。""" + return True + + +def assert_has_sender_id(sender_id: str) -> dict[str, Any]: + """ + 身份壳:无 sender_id 则 ok=False。 + + 不做企微换号;不入 Graph 的裁决由调用方执行。 + """ + if not (sender_id or "").strip(): + return {"ok": False, "error": "missing_sender_id"} + return {"ok": True} diff --git a/inquiry-agent/agent/quote_templates/__init__.py b/inquiry-agent/agent/quote_templates/__init__.py new file mode 100644 index 0000000..4ea65ce --- /dev/null +++ b/inquiry-agent/agent/quote_templates/__init__.py @@ -0,0 +1,112 @@ +""" +报价模板访问壳。 + +真相在主账:后台上传/启用/停用 → 对象存储存字节 → 主账存元数据。 +智能体生成报价前经 ledger 拉取已启用模板指针,再从对象存储取字节。 + +本文件职责:声明「从主账列模板」的空 API;可选本地缓存目录仅联调。 +禁止:把仓库 resources 当生产模板真相;禁止在本包发明价格或改 quoteVersion。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from agent.ledger import LedgerClient, LedgerResponse + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TemplateEntry: + """一条模板元数据(来自主账或联调缓存)。""" + + code: str + filename: str = "" + object_key: str = "" + enabled: bool = False + source: str = "ledger" + + +def local_cache_dir() -> Path: + """ + 可选本机缓存目录(非生产真相)。 + + 正式模板由后台上传;此目录仅开发落盘缓存,可空。 + """ + here = Path(__file__).resolve() + return here.parents[2] / "resources" / "quote_templates" + + +def list_templates_from_ledger( + client: Optional[LedgerClient] = None, +) -> LedgerResponse: + """ + 向主账查询已启用报价模板列表(壳)。 + + 正式路径以主账 OpenAPI 为准;当前返回 not_implemented,不假装本地文件即生产模板。 + """ + c = client or LedgerClient.from_settings() + logger.info("quote_templates.list_from_ledger(壳,未打真实 API)") + return LedgerResponse( + ok=False, + status_code=501, + error="not_implemented:list_quote_templates", + data={"hint": "admin_upload_via_ledger"}, + ) + + +def list_local_cache_files() -> list[TemplateEntry]: + """ + 列出本机缓存目录中的 xlsx(仅联调;可空)。 + + 不等于后台已启用模板。 + """ + root = local_cache_dir() + if not root.is_dir(): + return [] + out: list[TemplateEntry] = [] + for path in sorted(root.iterdir()): + if not path.is_file(): + continue + if path.suffix.lower() not in {".xlsx", ".xlsm", ".xltx"}: + continue + if path.name.startswith("."): + continue + out.append( + TemplateEntry( + code=path.stem, + filename=path.name, + object_key="", + enabled=False, + source="local_cache", + ) + ) + logger.info("quote_templates.local_cache count=%s", len(out)) + return out + + +def list_template_registry() -> list[TemplateEntry]: + """ + 兼容旧名:框架阶段返回本地缓存列表(可空)。 + + 生产应改用 list_templates_from_ledger + 对象存储下载。 + """ + return list_local_cache_files() + + +def registry_as_dicts() -> list[dict[str, Any]]: + """便于 JSON/自检。""" + return [ + { + "code": e.code, + "filename": e.filename, + "object_key": e.object_key, + "enabled": e.enabled, + "source": e.source, + } + for e in list_template_registry() + ] diff --git a/inquiry-agent/agent/redis_coord/__init__.py b/inquiry-agent/agent/redis_coord/__init__.py new file mode 100644 index 0000000..9977e89 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/__init__.py @@ -0,0 +1,33 @@ +""" +Redis 短命协调包(对齐 prompt/14)。 + +用途:Stream 队列、短幂等、限流、token 缓存。 +不用:六态、Graph checkpoint、Worker 全局单例、TMS 伪造开关。 + +命名说明: +- Redis URL 里的 /1 叫「逻辑库号 db1」,与现网 /0 隔离(Redis 本身没有 inquiry_robot 这种库名)。 +- 键前缀 inquiry_robot: 才是可读业务名;二者一起用。 +""" + +from agent.redis_coord.client import RedisClient, create_redis_client +from agent.redis_coord.idempotency import try_acquire as try_acquire_idempotency +from agent.redis_coord.rate_limit import RateLimitResult, allow as rate_limit_allow +from agent.redis_coord.runtime import ( + RedisRuntime, + get_redis_runtime, + shutdown_redis_runtime, +) +from agent.redis_coord.token_cache import get_wecom_token, set_wecom_token + +__all__ = [ + "RedisClient", + "RedisRuntime", + "RateLimitResult", + "create_redis_client", + "get_redis_runtime", + "shutdown_redis_runtime", + "try_acquire_idempotency", + "rate_limit_allow", + "get_wecom_token", + "set_wecom_token", +] diff --git a/inquiry-agent/agent/redis_coord/client.py b/inquiry-agent/agent/redis_coord/client.py new file mode 100644 index 0000000..a34fd1d --- /dev/null +++ b/inquiry-agent/agent/redis_coord/client.py @@ -0,0 +1,251 @@ +""" +Redis 连接与前缀客户端。 + +本文件职责:从 REDIS_URL 建连;统一 key();禁止 FLUSHALL / 全局单例锁 API。 +db 号:写在 URL 路径(如 …/1),与现网 …/0 隔离;业务名看 REDIS_KEY_PREFIX。 +线程:连接对象可被多线程共用(redis-py 连接池);业务认领仍按条,不靠本客户端当进程锁。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Optional, Protocol + +from agent.config import Settings +from agent.redis_coord.keys import join_key, normalize_prefix + +logger = logging.getLogger(__name__) + + +class RedisLike(Protocol): + """骨架可替换的最小 Redis 协议(真客户端或内存假实现)。""" + + def ping(self) -> Any: ... + def get(self, name: str) -> Any: ... + def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any: ... + def incr(self, name: str) -> int: ... + def expire(self, name: str, time: int) -> Any: ... + def delete(self, *names: str) -> Any: ... + def xadd(self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None) -> Any: ... + def xgroup_create( + self, name: str, groupname: str, id: str = "0", mkstream: bool = False + ) -> Any: ... + def xreadgroup( + self, + groupname: str, + consumername: str, + streams: dict, + count: Optional[int] = None, + block: Optional[int] = None, + ) -> Any: ... + def xack(self, name: str, groupname: str, *ids: str) -> Any: ... + def close(self) -> None: ... + + +@dataclass +class MemoryRedis: + """ + 本机骨架假 Redis:仅覆盖自检所需命令,不持久化。 + + 正式/并存禁止当生产;无 TTL 扫描后台,expire 只在 get 时懒过期。 + """ + + _kv: dict[str, tuple[Any, Optional[float]]] = field(default_factory=dict) + _streams: dict[str, list[tuple[str, dict]]] = field(default_factory=dict) + _groups: dict[str, set[str]] = field(default_factory=dict) + _seq: int = 0 + + def _now(self) -> float: + import time + + return time.time() + + def _alive(self, key: str) -> bool: + item = self._kv.get(key) + if item is None: + return False + _val, exp = item + if exp is not None and exp <= self._now(): + del self._kv[key] + return False + return True + + def ping(self) -> bool: + return True + + def get(self, name: str) -> Any: + if not self._alive(name): + return None + return self._kv[name][0] + + def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any: + if nx and self._alive(name): + return False + exp = self._now() + ex if ex else None + self._kv[name] = (value if isinstance(value, (bytes, str)) else str(value), exp) + return True + + def incr(self, name: str) -> int: + cur = self.get(name) + n = int(cur or 0) + 1 + exp = self._kv[name][1] if self._alive(name) else None + self._kv[name] = (str(n), exp) + return n + + def expire(self, name: str, time: int) -> bool: + if not self._alive(name): + return False + val, _ = self._kv[name] + self._kv[name] = (val, self._now() + time) + return True + + def delete(self, *names: str) -> int: + n = 0 + for name in names: + if name in self._kv: + del self._kv[name] + n += 1 + return n + + def xadd( + self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None + ) -> str: + self._seq += 1 + entry_id = id if id != "*" else f"1-{self._seq}" + buf = self._streams.setdefault(name, []) + # 字段值统一成 str,贴近 Redis + norm = {str(k): (v if isinstance(v, str) else str(v)) for k, v in fields.items()} + buf.append((entry_id, norm)) + if maxlen and len(buf) > maxlen: + del buf[0 : len(buf) - maxlen] + return entry_id + + def xgroup_create( + self, name: str, groupname: str, id: str = "0", mkstream: bool = False + ) -> bool: + if mkstream and name not in self._streams: + self._streams[name] = [] + key = f"{name}|{groupname}" + if key in self._groups: + raise Exception("BUSYGROUP Consumer Group name already exists") + self._groups[key] = set() + return True + + def xreadgroup( + self, + groupname: str, + consumername: str, + streams: dict, + count: Optional[int] = None, + block: Optional[int] = None, + ) -> list: + # 骨架:简单返回尚未标记的条目(忽略 PEL 细节) + out: list = [] + for stream_name, _id in streams.items(): + gkey = f"{stream_name}|{groupname}" + seen = self._groups.setdefault(gkey, set()) + entries = [] + for eid, fields in self._streams.get(stream_name, []): + if eid in seen: + continue + seen.add(eid) + entries.append((eid, fields)) + if count and len(entries) >= count: + break + if entries: + out.append((stream_name, entries)) + return out + + def xack(self, name: str, groupname: str, *ids: str) -> int: + return len(ids) + + def close(self) -> None: + self._kv.clear() + self._streams.clear() + self._groups.clear() + + +@dataclass +class RedisClient: + """ + 带前缀的 Redis 门面。 + + backend:redis(真连接)或 memory(自检)。 + 禁止提供 acquire_worker_singleton 之类方法。 + """ + + raw: RedisLike + key_prefix: str + backend: str + url_safe: str = "" + + def key(self, *parts: str) -> str: + """拼带前缀的完整 key。""" + return join_key(self.key_prefix, *parts) + + def ping(self) -> bool: + """连通性探测;失败抛异常。""" + return bool(self.raw.ping()) + + def close(self) -> None: + """关闭底层连接。""" + closer = getattr(self.raw, "close", None) + if callable(closer): + closer() + + +def _mask_url(url: str) -> str: + """日志用:隐藏密码。""" + if not url: + return "" + try: + from urllib.parse import urlsplit, urlunsplit + + parts = urlsplit(url) + if parts.password is None: + return url + netloc = parts.hostname or "" + if parts.port: + netloc = f"{netloc}:{parts.port}" + if parts.username: + netloc = f"{parts.username}:***@{netloc}" + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + except Exception: # noqa: BLE001 + return "" + + +def create_redis_client(settings: Settings) -> RedisClient: + """ + 按配置创建客户端。 + + - REDIS_BACKEND=memory 或未配 REDIS_URL 且非 prod:MemoryRedis + - 否则 redis.from_url(REDIS_URL),decode_responses=True + 副作用:可能对 Redis PING;不写业务键。 + """ + backend = (getattr(settings, "redis_backend", None) or "redis").strip().lower() + prefix = normalize_prefix(settings.redis_key_prefix) + if prefix.startswith("ytd:prod:"): + raise RuntimeError("禁止使用现网 Redis 前缀 ytd:prod:(prompt/13、14)") + + url = (settings.redis_url or "").strip() + force_memory = backend == "memory" or (not url and (settings.ytd_env or "").lower() != "prod") + + if force_memory: + if (settings.ytd_env or "").lower() == "prod" and backend == "memory": + raise RuntimeError("正式环境禁止 REDIS_BACKEND=memory") + logger.warning( + "Redis 使用 Memory 假实现(仅骨架);正式须 REDIS_URL 指向 db1 + 前缀 inquiry_robot:" + ) + return RedisClient(raw=MemoryRedis(), key_prefix=prefix, backend="memory") + + if not url: + raise RuntimeError("正式/已声明 redis 后端时必须配置 REDIS_URL(建议 …/1)") + + import redis + + raw = redis.Redis.from_url(url, decode_responses=True, socket_connect_timeout=3) + client = RedisClient(raw=raw, key_prefix=prefix, backend="redis", url_safe=_mask_url(url)) + client.ping() + logger.info("Redis 已连接 url=%s prefix=%s", client.url_safe, prefix) + return client diff --git a/inquiry-agent/agent/redis_coord/idempotency.py b/inquiry-agent/agent/redis_coord/idempotency.py new file mode 100644 index 0000000..0f9705c --- /dev/null +++ b/inquiry-agent/agent/redis_coord/idempotency.py @@ -0,0 +1,36 @@ +""" +短幂等:同一 idempotency_key 在 TTL 内不重复入队。 + +本文件职责:SET NX + EX;成功表示「首次」、失败表示「重复」。 +禁止:用本机制代替主账写幂等;禁止无 TTL 的永久键。 +""" + +from __future__ import annotations + +import logging + +from agent.redis_coord.client import RedisClient +from agent.redis_coord.keys import IDEMPOTENCY + +logger = logging.getLogger(__name__) + + +def try_acquire( + client: RedisClient, + *, + scope: str, + idempotency_key: str, + ttl_seconds: int = 300, +) -> bool: + """ + 尝试占用短幂等键。 + + 返回 True=首次(可入队);False=TTL 内重复。 + 副作用:写带过期的 Redis 键。 + """ + key = client.key(IDEMPOTENCY, scope, idempotency_key) + ok = client.raw.set(key, "1", ex=ttl_seconds, nx=True) + acquired = bool(ok) + if not acquired: + logger.info("短幂等命中重复 scope=%s key=%s", scope, idempotency_key) + return acquired diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py new file mode 100644 index 0000000..081961f --- /dev/null +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -0,0 +1,46 @@ +""" +Redis 键名拼装。 + +本文件职责:所有 Redis key 必须带 REDIS_KEY_PREFIX,避免与现网 ytd:prod: 冲突。 +说明:Redis 的「db1」是连接 URL 里的数字库号(/1),不是库名; +可读业务名靠前缀 inquiry_robot:(见 prompt/14、13)。 +禁止:写入 checkpoint / 六态 / Worker 全局单例锁键。 +""" + +from __future__ import annotations + + +def normalize_prefix(prefix: str) -> str: + """ + 规范化前缀:去掉首尾空白,确保以冒号结尾便于拼接。 + + 例:inquiry_robot → inquiry_robot: + """ + p = (prefix or "").strip() + if not p: + return "inquiry_robot:" + return p if p.endswith(":") else f"{p}:" + + +def join_key(prefix: str, *parts: str) -> str: + """ + 拼完整 key:{prefix}{part1}:{part2}:… + + 参数 parts 不得包含前缀本身;空段丢弃。 + """ + base = normalize_prefix(prefix) + segs = [str(p).strip(":") for p in parts if p is not None and str(p).strip() != ""] + if not segs: + return base.rstrip(":") + return base + ":".join(segs) + + +# 约定后缀(骨架);正式业务可扩展,勿改成无前缀的裸键 +STREAM_LLM = "stream:llm" +STREAM_RECOGNITION = "stream:recognition" +STREAM_PDF = "stream:pdf" +STREAM_ARCHIVE = "stream:archive" +STREAM_WAKE = "stream:wake" +IDEMPOTENCY = "idem" +RATE_LIMIT = "ratelimit" +TOKEN_WECOM = "token:wecom" diff --git a/inquiry-agent/agent/redis_coord/rate_limit.py b/inquiry-agent/agent/redis_coord/rate_limit.py new file mode 100644 index 0000000..2e5f755 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/rate_limit.py @@ -0,0 +1,53 @@ +""" +固定窗口限流骨架。 + +本文件职责:按 scope 计数;超限返回可重试,不写主账失败态。 +线程:可在出站/API 路径调用;禁止在限流键上做六态。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from agent.redis_coord.client import RedisClient +from agent.redis_coord.keys import RATE_LIMIT + +logger = logging.getLogger(__name__) + + +@dataclass +class RateLimitResult: + """限流判定结果。""" + + allowed: bool + count: int + limit: int + scope: str + + +def allow( + client: RedisClient, + *, + scope: str, + limit: int, + window_seconds: int = 60, +) -> RateLimitResult: + """ + 固定窗口:INCR + 首次 EXPIRE。 + + scope 示例:outbound:wecom / api:global / user:{sender_id} + """ + key = client.key(RATE_LIMIT, scope) + count = int(client.raw.incr(key)) + if count == 1: + client.raw.expire(key, window_seconds) + allowed = count <= limit + if not allowed: + logger.info( + "限流触发 scope=%s count=%s limit=%s", + scope, + count, + limit, + ) + return RateLimitResult(allowed=allowed, count=count, limit=limit, scope=scope) diff --git a/inquiry-agent/agent/redis_coord/runtime.py b/inquiry-agent/agent/redis_coord/runtime.py new file mode 100644 index 0000000..a05d92d --- /dev/null +++ b/inquiry-agent/agent/redis_coord/runtime.py @@ -0,0 +1,88 @@ +""" +Redis 协调运行时:进程内持有客户端与常用队列句柄。 + +本文件职责:懒创建 / 关闭;供 Worker、llm、outbox 使用。 +禁止:在 HTTP 回调里用本运行时同步等 LLM 结果。 +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from typing import Optional + +from agent.config import Settings, get_settings +from agent.redis_coord.client import RedisClient, create_redis_client +from agent.redis_coord.stream_queue import StreamQueue, llm_queue, recognition_queue + +logger = logging.getLogger(__name__) + + +@dataclass +class RedisRuntime: + """ + Redis 协调门面。 + + 生命周期:HTTP/Worker 启动时 get_redis_runtime();退出 shutdown。 + """ + + client: RedisClient + llm_stream: StreamQueue + recognition_stream: StreamQueue + settings: Settings + _closed: bool = field(default=False) + + @classmethod + def create(cls, settings: Optional[Settings] = None) -> "RedisRuntime": + """ + 建连并准备 LLM/识别 Stream 消费组(骨架)。 + + 副作用:ensure_group;memory 后端无外部 IO。 + """ + cfg = settings or get_settings() + client = create_redis_client(cfg) + llm = llm_queue(client) + recog = recognition_queue(client) + llm.ensure_group() + recog.ensure_group() + logger.info( + "RedisRuntime 就绪 backend=%s prefix=%s", + client.backend, + client.key_prefix, + ) + return cls( + client=client, + llm_stream=llm, + recognition_stream=recog, + settings=cfg, + ) + + def close(self) -> None: + """关闭底层连接。""" + if self._closed: + return + self.client.close() + self._closed = True + + +_runtime: Optional[RedisRuntime] = None +_guard = threading.Lock() + + +def get_redis_runtime(settings: Optional[Settings] = None) -> RedisRuntime: + """进程内单例;首次创建时可能连 Redis。""" + global _runtime + with _guard: + if _runtime is None: + _runtime = RedisRuntime.create(settings) + return _runtime + + +def shutdown_redis_runtime() -> None: + """进程退出时关闭。""" + global _runtime + with _guard: + if _runtime is not None: + _runtime.close() + _runtime = None diff --git a/inquiry-agent/agent/redis_coord/selftest.py b/inquiry-agent/agent/redis_coord/selftest.py new file mode 100644 index 0000000..fbbc25c --- /dev/null +++ b/inquiry-agent/agent/redis_coord/selftest.py @@ -0,0 +1,79 @@ +""" +Redis 协调骨架自检(默认 Memory 后端)。 + +用法: + set REDIS_BACKEND=memory + python -m agent.redis_coord.selftest +""" + +from __future__ import annotations + +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + os.environ["REDIS_BACKEND"] = "memory" + os.environ.setdefault("YTD_ENV", "test") + os.environ.setdefault("REDIS_KEY_PREFIX", "inquiry_robot:") + + from agent.config import get_settings + from agent.redis_coord import ( + get_redis_runtime, + get_wecom_token, + rate_limit_allow, + set_wecom_token, + shutdown_redis_runtime, + try_acquire_idempotency, + ) + from agent.redis_coord.keys import join_key + + get_settings.cache_clear() + # 证明:名字在前缀,不在「库名」 + k = join_key("inquiry_robot:", "stream", "llm") + assert k.startswith("inquiry_robot:"), k + logger.info("OK: 键前缀示例 %s(db 号在 REDIS_URL 的 /1,不在键名里)", k) + + rt = get_redis_runtime(get_settings()) + try: + assert rt.client.ping() + logger.info("OK: ping backend=%s", rt.client.backend) + + assert try_acquire_idempotency( + rt.client, scope="selftest", idempotency_key="once", ttl_seconds=60 + ) + assert not try_acquire_idempotency( + rt.client, scope="selftest", idempotency_key="once", ttl_seconds=60 + ) + logger.info("OK: 短幂等") + + r1 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60) + r2 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60) + r3 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60) + assert r1.allowed and r2.allowed and not r3.allowed + logger.info("OK: 限流") + + set_wecom_token(rt.client, agent_id="1000010", token="tok-selftest", ttl_seconds=30) + assert get_wecom_token(rt.client, agent_id="1000010") == "tok-selftest" + logger.info("OK: token 缓存") + + eid = rt.llm_stream.enqueue( + payload={"mode": "ordered_actions", "text": "hi"}, + idempotency_key="msg-1", + ) + claimed = rt.llm_stream.claim(consumer="selftest-worker", count=1, block_ms=10) + assert claimed and claimed[0][0] == eid + rt.llm_stream.ack(eid) + logger.info("OK: Stream 入队/认领/ack id=%s", eid) + logger.info("Redis 骨架自检通过") + return 0 + finally: + shutdown_redis_runtime() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/inquiry-agent/agent/redis_coord/stream_queue.py b/inquiry-agent/agent/redis_coord/stream_queue.py new file mode 100644 index 0000000..c549028 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/stream_queue.py @@ -0,0 +1,121 @@ +""" +Redis Stream 任务队列骨架。 + +本文件职责:XADD 入队、确保消费组、XREADGROUP 认领占位;结果不在回调线程 wait。 +禁止:Stream 与 PG 任务表双写两套真相(选型后只留一条);禁止全局单例锁。 +线程:enqueue 可在 HTTP 入队后立刻返回;claim 只在 Worker 槽调用。 +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.redis_coord.client import RedisClient +from agent.redis_coord.keys import STREAM_LLM, STREAM_RECOGNITION + +logger = logging.getLogger(__name__) + + +@dataclass +class StreamQueue: + """ + 一条 Stream + 一个消费组的骨架封装。 + + stream_suffix:如 stream:llm;完整 key = prefix + suffix。 + """ + + client: RedisClient + stream_suffix: str + group: str = "inquiry-workers" + maxlen: int = 10000 + + @property + def stream_key(self) -> str: + return self.client.key(self.stream_suffix) + + def ensure_group(self) -> None: + """ + 创建消费组(已存在则忽略)。 + + 副作用:可能 MKSTREAM;不消费消息。 + """ + try: + self.client.raw.xgroup_create( + self.stream_key, self.group, id="0", mkstream=True + ) + logger.info("Stream 消费组已创建 stream=%s group=%s", self.stream_key, self.group) + except Exception as exc: # noqa: BLE001 — BUSYGROUP 等 + if "BUSYGROUP" in str(exc).upper(): + return + # redis-py 用 ResponseError + msg = str(exc).upper() + if "BUSYGROUP" in msg or "ALREADY EXISTS" in msg: + return + raise + + def enqueue(self, *, payload: dict[str, Any], idempotency_key: str = "") -> str: + """ + 入队一条任务(只写 Stream,不等待结果)。 + + 返回:stream entry id。 + 调用方可在 HTTP 线程:禁止随后阻塞等 LLM。 + """ + fields = { + "payload": json.dumps(payload, ensure_ascii=False), + "idempotency_key": idempotency_key or "", + } + entry_id = self.client.raw.xadd( + self.stream_key, fields, id="*", maxlen=self.maxlen + ) + logger.info( + "Stream 入队 stream=%s id=%s idem=%s", + self.stream_key, + entry_id, + idempotency_key or "-", + ) + return str(entry_id) + + def claim( + self, *, consumer: str, count: int = 1, block_ms: int = 1000 + ) -> list[tuple[str, dict[str, Any]]]: + """ + 认领最多 count 条(骨架:无 PEL 租约心跳,后续补)。 + + 必须在 Worker 槽调用;返回 [(entry_id, payload_dict), …]。 + """ + rows = self.client.raw.xreadgroup( + self.group, + consumer, + streams={self.stream_key: ">"}, + count=count, + block=block_ms, + ) + out: list[tuple[str, dict[str, Any]]] = [] + if not rows: + return out + for _stream, entries in rows: + for entry_id, fields in entries: + raw_payload = fields.get("payload") or "{}" + try: + data = json.loads(raw_payload) + except json.JSONDecodeError: + data = {"raw": raw_payload} + out.append((str(entry_id), data)) + return out + + def ack(self, entry_id: str) -> None: + """确认处理完成。""" + self.client.raw.xack(self.stream_key, self.group, entry_id) + + +def llm_queue(client: RedisClient) -> StreamQueue: + """LLM 任务 Stream(骨架)。""" + return StreamQueue(client=client, stream_suffix=STREAM_LLM) + + +def recognition_queue(client: RedisClient) -> StreamQueue: + """识别任务 Stream(骨架)。""" + return StreamQueue(client=client, stream_suffix=STREAM_RECOGNITION) diff --git a/inquiry-agent/agent/redis_coord/token_cache.py b/inquiry-agent/agent/redis_coord/token_cache.py new file mode 100644 index 0000000..f71af1d --- /dev/null +++ b/inquiry-agent/agent/redis_coord/token_cache.py @@ -0,0 +1,42 @@ +""" +企微 access_token 等短缓存。 + +本文件职责:GET/SET+TTL;未命中由调用方再打企微,失败可回退。 +禁止:把 token 当业务真相;挂了必须能重拉,不要把工单打成「未认证」。 +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from agent.redis_coord.client import RedisClient +from agent.redis_coord.keys import TOKEN_WECOM + +logger = logging.getLogger(__name__) + + +def get_wecom_token(client: RedisClient, *, agent_id: str) -> Optional[str]: + """读取缓存 token;未命中返回 None。""" + key = client.key(TOKEN_WECOM, agent_id) + val = client.raw.get(key) + if val is None: + return None + return val if isinstance(val, str) else val.decode("utf-8") + + +def set_wecom_token( + client: RedisClient, + *, + agent_id: str, + token: str, + ttl_seconds: int = 7000, +) -> None: + """ + 写入 token(默认 TTL 略短于企微 7200s)。 + + 副作用:覆盖同 agent_id 缓存键。 + """ + key = client.key(TOKEN_WECOM, agent_id) + client.raw.set(key, token, ex=ttl_seconds) + logger.info("企微 token 已缓存 agent_id=%s ttl=%s", agent_id, ttl_seconds) diff --git a/inquiry-agent/agent/routing/__init__.py b/inquiry-agent/agent/routing/__init__.py index d551f09..5d6a145 100644 --- a/inquiry-agent/agent/routing/__init__.py +++ b/inquiry-agent/agent/routing/__init__.py @@ -2,5 +2,97 @@ 意图路由:DeepSeek A → 唯一 RouteDecision。 禁止:在本包产生业务副作用(建单、发消息、改六态)。 -骨架:仅占位。 +正式路径:DeepSeekARouter(strict tool);route_stub 仅兼容旧自检名。 """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from agent.llm import LlmGateway, LlmMode +from agent.routing.decision import RouteDecision +from agent.routing.deepseek_a import DeepSeekARouter +from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound, resolve_handler +from agent.routing.ordered_actions import ( + ORDERED_ACTIONS_TOOL_NAME, + OrderedActionsError, + OrderedActionsResult, + ordered_actions_tool_definition, + parse_ordered_actions_arguments, +) + +# IntentRouter 定义见下 + + +logger = logging.getLogger(__name__) + + +@dataclass +class IntentRouter: + """ + 路由门面。 + + - route():优先 DeepSeek A(默认 stub,不开费) + - enqueue_ordered_actions():快回路径入 LLM Stream + """ + + llm: Optional[LlmGateway] = None + deepseek_a: Optional[DeepSeekARouter] = None + allow_network: bool = False + + def _a(self) -> DeepSeekARouter: + if self.deepseek_a is not None: + return self.deepseek_a + return DeepSeekARouter.from_settings(allow_network=self.allow_network) + + def route_stub(self, *, text: str, sender_id: str) -> RouteDecision: + """ + 兼容旧名:等价于无网络的 A stub fixture。 + + 正式生产请用 route() 并在 Worker 开 allow_network + 数据确认。 + """ + return self._a().route_stub_fixture(text=text, sender_id=sender_id) + + def route(self, *, text: str, sender_id: str) -> RouteDecision: + """一条入站一个 RouteDecision。""" + return self._a().route(text=text, sender_id=sender_id) + + def enqueue_ordered_actions( + self, + *, + text: str, + sender_id: str, + idempotency_key: str, + ) -> str: + """ + 将 A 路由任务入 LLM 队列(快回路径用)。 + + 返回 stream entry id;无 LlmGateway 时抛错。 + """ + if self.llm is None: + raise RuntimeError("IntentRouter 未注入 LlmGateway") + result = self.llm.enqueue( + mode=LlmMode.ORDERED_ACTIONS, + payload={"text": text, "sender_id": sender_id}, + idempotency_key=idempotency_key, + ) + if not result.accepted: + raise RuntimeError(result.reason or "enqueue_rejected") + return result.entry_id + + +__all__ = [ + "RouteDecision", + "IntentRouter", + "DeepSeekARouter", + "ORDERED_ACTIONS_TOOL_NAME", + "OrderedActionsError", + "OrderedActionsResult", + "ordered_actions_tool_definition", + "parse_ordered_actions_arguments", + "HANDLER_REGISTRY", + "dispatch_inbound", + "resolve_handler", +] diff --git a/inquiry-agent/agent/routing/decision.py b/inquiry-agent/agent/routing/decision.py new file mode 100644 index 0000000..d601b8a --- /dev/null +++ b/inquiry-agent/agent/routing/decision.py @@ -0,0 +1,25 @@ +""" +RouteDecision 值对象。 + +本文件只放冻结路由结论;解析逻辑见 ordered_actions.py;副作用在 handlers。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class RouteDecision: + """ + 唯一路由结论(一条入站对应一个)。 + + intent:如 ordinary_text_inquiry / attachment_inquiry / reject_no_sender + thread_id:继续本单时填工单号;新开可空由 Handler 生成。 + """ + + intent: str + thread_id: str = "" + confidence: float = 0.0 + raw: dict[str, Any] = field(default_factory=dict) diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py new file mode 100644 index 0000000..56b3cd0 --- /dev/null +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -0,0 +1,159 @@ +""" +DeepSeek A 调用壳:组装 messages + route_decision tool,解析为 RouteDecision。 + +本文件职责:Worker 槽内跑 A 路由;默认不打外网(需 LLM_DATA_USAGE_CONFIRMED)。 +禁止:在企微回调线程同步调用本模块的网络路径。 +""" + +from __future__ import annotations + +import hashlib +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from agent.config import Settings, get_settings +from agent.llm.http_client import LlmHttpClient, LlmHttpResult +from agent.llm.modes import LlmMode +from agent.routing.decision import RouteDecision +from agent.routing.ordered_actions import ( + ORDERED_ACTIONS_TOOL_NAME, + OrderedActionsError, + extract_tool_arguments_from_chat_raw, + ordered_actions_tool_definition, + parse_ordered_actions_arguments, +) + +logger = logging.getLogger(__name__) + +_SYSTEM = ( + "You are DeepSeek-A for inquiry routing. " + "You must call the function tool route_decision exactly once. " + "Do not write user-facing text. Do not extract business fields." +) + + +def _digest_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +@dataclass +class DeepSeekARouter: + """ + DeepSeek A 路由器。 + + allow_network:是否允许真 HTTP;仍受 settings.llm_data_usage_confirmed 约束。 + """ + + settings: Settings + http: LlmHttpClient + allow_network: bool = False + + @classmethod + def from_settings( + cls, + settings: Optional[Settings] = None, + *, + allow_network: bool = False, + ) -> "DeepSeekARouter": + cfg = settings or get_settings() + return cls( + settings=cfg, + http=LlmHttpClient.from_settings(cfg), + allow_network=allow_network, + ) + + def build_messages(self, *, text: str, sender_id: str) -> list[dict[str, Any]]: + """构造仅含 inbound_facts 的 messages(不做字段抽取)。""" + facts = { + "sender_id": sender_id, + "text": text, + "text_sha256": _digest_text(text or ""), + } + return [ + {"role": "system", "content": _SYSTEM}, + { + "role": "user", + "content": ( + "inbound_facts (JSON-like):\n" + f"sender_id={facts['sender_id']}\n" + f"text_sha256={facts['text_sha256']}\n" + f"text={facts['text']}\n" + "Call route_decision once." + ), + }, + ] + + def route_from_tool_args(self, args: dict[str, Any] | str) -> RouteDecision: + """仅解析已有 tool 参数(单测 / stub / 回放)。""" + return parse_ordered_actions_arguments(args).to_route_decision() + + def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision: + """ + 无外网固定夹具:把非空文本标成 ordinary_text_inquiry。 + + 仅骨架联调;正式生产必须走模型或已落库的 tool 回放。 + """ + if not sender_id: + return RouteDecision(intent="reject_no_sender", confidence=1.0) + content = (text or "").strip() + if not content: + return RouteDecision(intent="ignore_empty", confidence=1.0) + digest = _digest_text(content) + args = { + "decision_id": f"STUB-{digest[:16]}", + "intent": "ordinary_text_inquiry", + "ordered_action": { + "action_type": "ORDINARY_TEXT_INQUIRY", + "order": 1, + "target": {"work_order_no": ""}, + "evidence": { + "source_ref": f"inbound:{sender_id}", + "content_digest": digest, + }, + }, + } + logger.info("DeepSeekA stub fixture sender=%s", sender_id) + return self.route_from_tool_args(args) + + def route(self, *, text: str, sender_id: str) -> RouteDecision: + """ + 执行 A 路由。 + + 无 sender_id → reject;未开网络或未确认数据用途 → stub fixture; + 开网络 → tool_choice 强制 route_decision。 + """ + if not sender_id: + return RouteDecision(intent="reject_no_sender", confidence=1.0) + if not self.allow_network or not self.settings.llm_data_usage_confirmed: + if self.allow_network and not self.settings.llm_data_usage_confirmed: + logger.warning( + "LLM_DATA_USAGE_CONFIRMED 未确认,拒绝打真实 DeepSeek A,改用 stub" + ) + return self.route_stub_fixture(text=text, sender_id=sender_id) + + messages = self.build_messages(text=text, sender_id=sender_id) + tools = [ordered_actions_tool_definition()] + result: LlmHttpResult = self.http.chat( + mode=LlmMode.ORDERED_ACTIONS, + messages=messages, + tools=tools, + tool_choice={"type": "function", "function": {"name": ORDERED_ACTIONS_TOOL_NAME}}, + ) + if not result.ok: + logger.warning("DeepSeek A HTTP 失败 err=%s", result.error) + return RouteDecision( + intent="route_model_failed", + confidence=0.0, + raw={"error": result.error, "provider": result.provider}, + ) + try: + args = extract_tool_arguments_from_chat_raw(result.raw) + return self.route_from_tool_args(args) + except OrderedActionsError as exc: + logger.warning("DeepSeek A 解析失败: %s", exc) + return RouteDecision( + intent="route_parse_failed", + confidence=0.0, + raw={"error": str(exc)}, + ) diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py new file mode 100644 index 0000000..1058737 --- /dev/null +++ b/inquiry-agent/agent/routing/dispatch.py @@ -0,0 +1,128 @@ +""" +入站分发壳:RouteDecision.intent → 唯一 handler。 + +本文件职责:维护意图→可调用对象表;dispatch_inbound 只查表调用。 +禁止:在本文件写补问/建单/TMS 业务;禁止一个函数处理全部卡片。 +""" + +from __future__ import annotations + +import logging +from typing import Callable, Optional + +from agent.channel.wecom.models import InboundMessage +from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry +from agent.handlers.echo_text import handle_echo_text +from agent.routing.decision import RouteDecision + +logger = logging.getLogger(__name__) + +# Handler 签名约定:只收 InboundMessage(出站/store 由 handler 自行取依赖) +HandlerFn = Callable[[InboundMessage], None] + + +def _noop(message: InboundMessage) -> None: + """忽略类意图:不做事。""" + logger.info("dispatch.noop sender=%s msg=%s", message.sender_id, message.message_id) + + +def _wrap_echo(message: InboundMessage) -> None: + """ + 联调占位:文字意图暂走 echo(正式应换成 new_inquiry / Graph)。 + + 框架接线用;不含字段抽取。 + """ + from agent.channel.queue import get_message_store + from agent.config import get_settings + + settings = get_settings() + handle_echo_text( + message, + get_message_store(), + ytd_env=settings.ytd_env, + reply_template=settings.wecom_callback_reply_text, + ) + + +def _wrap_new(message: InboundMessage) -> None: + new_inquiry.handle_new_inquiry(message) + + +def _wrap_continue(message: InboundMessage) -> None: + continue_thread.handle_continue_thread(message, thread_id="") + + +def _wrap_card(message: InboundMessage) -> None: + # 框架:卡片字段尚未从 payload 解析;空壳用默认 + card_action.handle_card_action( + sender_id=message.sender_id, + thread_id="", + wait_version=0, + action="unknown", + payload={}, + ) + + +def _wrap_close(message: InboundMessage) -> None: + close_deal.handle_close_deal( + sender_id=message.sender_id, + inquiry_no="", + outcome="unknown", + ) + + +def _wrap_abnormal(message: InboundMessage) -> None: + abnormal.handle_abnormal(sender_id=message.sender_id, inquiry_no="", reason="dispatch") + + +# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林 +HANDLER_REGISTRY: dict[str, HandlerFn] = { + "echo_text": _wrap_echo, + "ordinary_text_inquiry": _wrap_echo, # 联调:有回复;正式换业务 handler + "ordinary_text_other": _wrap_echo, + "attachment_inquiry": _wrap_new, + "image_inquiry": _wrap_new, + "multi_segment_transport": _wrap_new, + "tms_read_only_quote": _wrap_continue, + "new_inquiry": _wrap_new, + "continue_thread": _wrap_continue, + "card_action": _wrap_card, + "close_deal": _wrap_close, + "abnormal": _wrap_abnormal, + "reject_no_sender": _noop, + "ignore_empty": _noop, + "stub_needs_model": _wrap_echo, +} + + +def resolve_handler(intent: str) -> Optional[HandlerFn]: + """查表;未知意图返回 None。""" + return HANDLER_REGISTRY.get(intent) + + +def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: + """ + 按 RouteDecision 调用唯一 handler。 + + 返回:实际调用的 intent 名,或 unknown_intent / skipped。 + 副作用:仅 handler 内产生;本函数不做业务。 + """ + if not message.has_sender() and decision.intent != "reject_no_sender": + logger.warning("dispatch 拒绝:无 sender_id") + HANDLER_REGISTRY["reject_no_sender"](message) + return "reject_no_sender" + + fn = resolve_handler(decision.intent) + if fn is None: + logger.warning("dispatch 未知意图 intent=%s → noop", decision.intent) + _noop(message) + return "unknown_intent" + + logger.info( + "dispatch intent=%s sender=%s msg=%s", + decision.intent, + message.sender_id, + message.message_id, + ) + fn(message) + return decision.intent diff --git a/inquiry-agent/agent/routing/ordered_actions.py b/inquiry-agent/agent/routing/ordered_actions.py new file mode 100644 index 0000000..738b1cc --- /dev/null +++ b/inquiry-agent/agent/routing/ordered_actions.py @@ -0,0 +1,251 @@ +""" +DeepSeek A:`ordered_actions` / `route_decision` 工具合同与解析。 + +本文件职责: +- 给出唯一 strict tool schema(对齐现网 framework_route_contract 垂直切片); +- 把模型 tool_call 参数校验并投影为 RouteDecision; +- 不产生业务副作用(不建单、不发消息、不改六态)。 + +为何独立成文件:schema 与 IntentRouter / HTTP 客户端解耦,便于单测与合同演进。 +""" + +from __future__ import annotations + +import json +import logging +import unicodedata +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional + +from agent.routing.decision import RouteDecision + +logger = logging.getLogger(__name__) + +ORDERED_ACTIONS_TOOL_NAME = "route_decision" + +# action_type(模型枚举)→ 业务意图(RouteDecision.intent) +ACTION_INTENTS: dict[str, str] = { + "ATTACHMENT_DOCUMENT_INQUIRY": "attachment_inquiry", + "ATTACHMENT_IMAGE_INQUIRY": "image_inquiry", + "MULTI_SEGMENT_TRANSPORT": "multi_segment_transport", + "TMS_READ_ONLY_QUOTE": "tms_read_only_quote", + "ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry", + "ORDINARY_TEXT_OTHER": "ordinary_text_other", +} + + +class OrderedActionsError(ValueError): + """ordered_actions 合同被违反(缺键、枚举不符、证据非法等)。""" + + +def _nfc(value: str) -> str: + """非空 NFC 规范化;首尾空白视为非法(与现网一致)。""" + result = unicodedata.normalize("NFC", str(value)) + if not result or result != result.strip(): + raise OrderedActionsError("字符串须为非空 NFC 且无首尾空白") + return result + + +def ordered_actions_tool_definition() -> dict[str, Any]: + """ + 返回 DeepSeek A 唯一 strict tool。 + + 模型必须且只能调用本 tool;不得输出用户可见正文。 + """ + action_types = sorted(ACTION_INTENTS) + intents = sorted(set(ACTION_INTENTS.values())) + target_schema = { + "type": "object", + "additionalProperties": False, + "properties": {"work_order_no": {"type": "string"}}, + "required": ["work_order_no"], + } + evidence_schema = { + "type": "object", + "additionalProperties": False, + "properties": { + "source_ref": {"type": "string"}, + "content_digest": {"type": "string"}, + }, + "required": ["source_ref", "content_digest"], + } + action_schema = { + "type": "object", + "additionalProperties": False, + "properties": { + "action_type": {"type": "string", "enum": action_types}, + "order": {"type": "integer", "const": 1}, + "target": target_schema, + "evidence": evidence_schema, + }, + "required": ["action_type", "order", "target", "evidence"], + } + parameters = { + "type": "object", + "additionalProperties": False, + "properties": { + "decision_id": {"type": "string"}, + "intent": {"type": "string", "enum": intents}, + # 工具入参用单数;解析后统一成 ordered_actions 列表 + "ordered_action": action_schema, + }, + "required": ["decision_id", "intent", "ordered_action"], + } + return { + "type": "function", + "function": { + "name": ORDERED_ACTIONS_TOOL_NAME, + "description": ( + "Return exactly one frozen business route decision " + "for the current inbound event." + ), + "strict": True, + "parameters": parameters, + }, + } + + +@dataclass(frozen=True) +class OrderedActionsResult: + """校验后的 A 路由冻结结果(一条入站一条)。""" + + decision_id: str + intent: str + action_type: str + work_order_no: str + evidence_source_ref: str + evidence_content_digest: str + raw: dict[str, Any] = field(default_factory=dict) + + def to_route_decision(self, *, confidence: float = 1.0) -> RouteDecision: + """投影为唯一 RouteDecision(仍无副作用)。""" + return RouteDecision( + intent=self.intent, + thread_id=self.work_order_no or "", + confidence=confidence, + raw={ + "decision_id": self.decision_id, + "action_type": self.action_type, + "ordered_actions": self.raw, + "source": "deepseek_a", + }, + ) + + +def _coerce_tool_args(value: Any) -> dict[str, Any]: + """接受 dict 或 JSON 字符串。""" + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + data = json.loads(value) + except json.JSONDecodeError as exc: + raise OrderedActionsError("tool arguments 不是合法 JSON") from exc + if not isinstance(data, dict): + raise OrderedActionsError("tool arguments 必须是对象") + return data + raise OrderedActionsError("tool arguments 类型非法") + + +def parse_ordered_actions_arguments( + value: Mapping[str, Any] | str | dict[str, Any], +) -> OrderedActionsResult: + """ + 解析并校验 route_decision tool 参数。 + + 副作用:无。失败抛 OrderedActionsError。 + """ + data = _coerce_tool_args(value) + decision_id = _nfc(str(data.get("decision_id") or "")) + intent = _nfc(str(data.get("intent") or "")) + action = data.get("ordered_action") + if action is None and isinstance(data.get("ordered_actions"), list): + actions = data["ordered_actions"] + if len(actions) != 1 or not isinstance(actions[0], dict): + raise OrderedActionsError("ordered_actions 必须恰好一条") + action = actions[0] + if not isinstance(action, dict): + raise OrderedActionsError("缺少 ordered_action") + + action_type = _nfc(str(action.get("action_type") or "")) + if action_type not in ACTION_INTENTS: + raise OrderedActionsError(f"未知 action_type: {action_type}") + if ACTION_INTENTS[action_type] != intent: + raise OrderedActionsError("action_type 与 intent 不一致") + if action.get("order") != 1: + raise OrderedActionsError("order 必须为 1(当前垂直切片)") + + target = action.get("target") + if not isinstance(target, dict): + raise OrderedActionsError("target 必须为对象") + work_order_no = str(target.get("work_order_no") or "").strip() + # 允许空工单号(新询价);若有值则 NFC + if work_order_no: + work_order_no = _nfc(work_order_no) + + evidence = action.get("evidence") + # 工具 schema 是单对象;兼容列表写法 + if isinstance(evidence, list): + if len(evidence) != 1 or not isinstance(evidence[0], dict): + raise OrderedActionsError("evidence 须恰好一条") + evidence = evidence[0] + if not isinstance(evidence, dict): + raise OrderedActionsError("evidence 必须为对象") + source_ref = _nfc(str(evidence.get("source_ref") or "")) + digest = str(evidence.get("content_digest") or "").lower() + if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise OrderedActionsError("content_digest 必须是 64 位小写 hex") + + frozen = { + "decision_id": decision_id, + "intent": intent, + "ordered_actions": [ + { + "action_type": action_type, + "order": 1, + "target": {"work_order_no": work_order_no or None}, + "evidence": [ + {"source_ref": source_ref, "content_digest": digest}, + ], + } + ], + } + logger.info( + "ordered_actions 已校验 decision_id=%s intent=%s action=%s", + decision_id, + intent, + action_type, + ) + return OrderedActionsResult( + decision_id=decision_id, + intent=intent, + action_type=action_type, + work_order_no=work_order_no, + evidence_source_ref=source_ref, + evidence_content_digest=digest, + raw=frozen, + ) + + +def extract_tool_arguments_from_chat_raw( + raw: Optional[Mapping[str, Any]], + *, + tool_name: str = ORDERED_ACTIONS_TOOL_NAME, +) -> dict[str, Any]: + """ + 从 OpenAI 兼容 chat 响应中取出指定 tool 的 arguments。 + + 未找到则抛 OrderedActionsError。 + """ + if not raw: + raise OrderedActionsError("空模型响应") + choices = raw.get("choices") or [] + if not choices: + raise OrderedActionsError("无 choices") + message = (choices[0] or {}).get("message") or {} + tool_calls = message.get("tool_calls") or [] + for call in tool_calls: + fn = (call or {}).get("function") or {} + if str(fn.get("name") or "") == tool_name: + return _coerce_tool_args(fn.get("arguments")) + raise OrderedActionsError(f"未调用 tool: {tool_name}") diff --git a/inquiry-agent/agent/runtime_db/__init__.py b/inquiry-agent/agent/runtime_db/__init__.py new file mode 100644 index 0000000..ea7f18a --- /dev/null +++ b/inquiry-agent/agent/runtime_db/__init__.py @@ -0,0 +1,134 @@ +""" +PostgreSQL 运行账连接与迁移。 + +本包职责:解析 PG_DSN、安全拒绝现网库名、执行版本化 SQL。 +禁止:智能体经本包写 MySQL 工单表;禁止 FLUSH / 删现网库。 +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + +# 现网运行账库名:新系统迁移脚本绝对不能碰 +_FORBIDDEN_DB_NAMES = frozenset({"ytd_runtime"}) + + +def migrations_dir() -> Path: + """返回 inquiry-agent/migrations 目录。""" + return Path(__file__).resolve().parents[2] / "migrations" + + +def assert_safe_database(dsn: str) -> str: + """ + 从 DSN 解析库名并校验。 + + 返回库名;若为 ytd_runtime 则抛错。 + """ + if not dsn: + raise RuntimeError("PG_DSN 为空,无法迁移") + parsed = urlparse(dsn) + db = (parsed.path or "").lstrip("/").split("?")[0] + if not db: + raise RuntimeError(f"无法从 DSN 解析库名: {dsn[:32]}...") + if db in _FORBIDDEN_DB_NAMES: + raise RuntimeError(f"禁止对现网库执行迁移: {db}") + return db + + +def connect_psycopg(dsn: str): + """ + 打开 psycopg 连接(调用方负责 close)。 + + 副作用:网络连库。 + """ + import psycopg + + return psycopg.connect(dsn, connect_timeout=5) + + +def apply_migrations(settings: Optional[Settings] = None) -> list[str]: + """ + 按文件名顺序执行尚未应用的 *.sql。 + + 返回:本次新执行的 version 列表。 + 无 DSN:抛错(自检可用 --dry-list)。 + """ + cfg = settings or get_settings() + dsn = cfg.resolve_pg_dsn() + db_name = assert_safe_database(dsn) + logger.info("准备迁移 database=%s", db_name) + + sql_files = sorted(migrations_dir().glob("*.sql")) + if not sql_files: + logger.warning("migrations 目录无 .sql 文件") + return [] + + applied: list[str] = [] + with connect_psycopg(dsn) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + description TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.commit() + for path in sql_files: + version = path.stem + row = conn.execute( + "SELECT 1 FROM schema_migrations WHERE version = %s", + (version,), + ).fetchone() + if row: + logger.info("跳过已应用 migration=%s", version) + continue + sql = path.read_text(encoding="utf-8") + # 去掉纯注释行后的空脚本保护 + body = "\n".join( + ln for ln in sql.splitlines() if not ln.strip().startswith("--") + ).strip() + if not body: + continue + logger.info("执行 migration=%s", version) + conn.execute(sql) + # 若脚本未自行写入 schema_migrations,补写 + exists = conn.execute( + "SELECT 1 FROM schema_migrations WHERE version = %s", + (version,), + ).fetchone() + if not exists: + conn.execute( + "INSERT INTO schema_migrations(version, description) VALUES (%s, %s)", + (version, path.name), + ) + conn.commit() + applied.append(version) + return applied + + +def list_migration_files() -> list[str]: + """列出迁移文件名(不连库)。""" + return [p.name for p in sorted(migrations_dir().glob("*.sql"))] + + +# 会话 / 停等仓储壳(表已在 001;本包只提供读写空实现) +from agent.runtime_db.session_store import ( # noqa: E402 + MemorySessionStore, + SessionRecord, + create_session_store, +) +from agent.runtime_db.suspend_store import ( # noqa: E402 + MemorySuspendStore, + SuspendRecord, + create_suspend_store, +) diff --git a/inquiry-agent/agent/runtime_db/migrate.py b/inquiry-agent/agent/runtime_db/migrate.py new file mode 100644 index 0000000..683adcf --- /dev/null +++ b/inquiry-agent/agent/runtime_db/migrate.py @@ -0,0 +1,49 @@ +""" +运行账迁移 CLI。 + +用法: + python -m agent.runtime_db.migrate --list + python -m agent.runtime_db.migrate + +需配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER;目标库必须是 inquiry_robot_runtime(禁止 ytd_runtime)。 +""" + +from __future__ import annotations + +import argparse +import logging +import sys + +from agent.config import get_settings +from agent.runtime_db import apply_migrations, assert_safe_database, list_migration_files + + +def main(argv: list[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + parser = argparse.ArgumentParser(description="inquiry_robot_runtime 迁移") + parser.add_argument("--list", action="store_true", help="只列出 SQL 文件,不连库") + args = parser.parse_args(argv) + + if args.list: + for name in list_migration_files(): + print(name) + return 0 + + settings = get_settings() + dsn = settings.resolve_pg_dsn() + if not dsn: + logging.error("未配置 PG_DSN / PG_*,无法迁移") + return 2 + try: + db = assert_safe_database(dsn) + logging.info("目标库 %s", db) + applied = apply_migrations(settings) + logging.info("新应用 %s 个迁移: %s", len(applied), applied or "(无)") + return 0 + except Exception as exc: # noqa: BLE001 + logging.exception("迁移失败: %s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/inquiry-agent/agent/runtime_db/session_store.py b/inquiry-agent/agent/runtime_db/session_store.py new file mode 100644 index 0000000..1198245 --- /dev/null +++ b/inquiry-agent/agent/runtime_db/session_store.py @@ -0,0 +1,172 @@ +""" +会话仓储壳:conversation_session 读写。 + +本文件职责:按 sender_id / thread_id 存取活动工单与打断栈。 +禁止:写入主账六态;本壳不做询价业务分支。 +线程:短连接;Memory 后端仅自检。 +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class SessionRecord: + """一条会话快照(非六态真相)。""" + + thread_id: str + sender_id: str + active_inquiry_no: str = "" + interrupt_stack: list[Any] = field(default_factory=list) + meta: dict[str, Any] = field(default_factory=dict) + + +class MemorySessionStore: + """内存会话壳(自检 / 无 PG)。""" + + def __init__(self) -> None: + self._by_thread: dict[str, SessionRecord] = {} + self._lock = threading.Lock() + + def get(self, thread_id: str) -> Optional[SessionRecord]: + with self._lock: + rec = self._by_thread.get(thread_id) + return None if rec is None else SessionRecord( + thread_id=rec.thread_id, + sender_id=rec.sender_id, + active_inquiry_no=rec.active_inquiry_no, + interrupt_stack=list(rec.interrupt_stack), + meta=dict(rec.meta), + ) + + def upsert(self, record: SessionRecord) -> None: + with self._lock: + self._by_thread[record.thread_id] = SessionRecord( + thread_id=record.thread_id, + sender_id=record.sender_id, + active_inquiry_no=record.active_inquiry_no, + interrupt_stack=list(record.interrupt_stack), + meta=dict(record.meta), + ) + logger.info( + "session.memory.upsert thread=%s sender=%s", + record.thread_id, + record.sender_id, + ) + + def find_by_sender(self, sender_id: str) -> list[SessionRecord]: + with self._lock: + return [ + SessionRecord( + thread_id=r.thread_id, + sender_id=r.sender_id, + active_inquiry_no=r.active_inquiry_no, + interrupt_stack=list(r.interrupt_stack), + meta=dict(r.meta), + ) + for r in self._by_thread.values() + if r.sender_id == sender_id + ] + + +class PostgresSessionStore: + """ + PG 会话壳。 + + 须已执行 001 迁移;读写 conversation_session,无业务推导。 + """ + + def __init__(self, dsn: str) -> None: + assert_safe_database(dsn) + self._dsn = dsn + + def get(self, thread_id: str) -> Optional[SessionRecord]: + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + """ + SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta + FROM conversation_session WHERE thread_id = %s + """, + (thread_id,), + ).fetchone() + if not row: + return None + return SessionRecord( + thread_id=str(row[0]), + sender_id=str(row[1]), + active_inquiry_no=str(row[2] or ""), + interrupt_stack=list(row[3] or []), + meta=dict(row[4] or {}), + ) + + def upsert(self, record: SessionRecord) -> None: + import json + + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO conversation_session ( + thread_id, sender_id, active_inquiry_no, interrupt_stack, meta, updated_at + ) VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s) + ON CONFLICT (thread_id) DO UPDATE SET + sender_id = EXCLUDED.sender_id, + active_inquiry_no = EXCLUDED.active_inquiry_no, + interrupt_stack = EXCLUDED.interrupt_stack, + meta = EXCLUDED.meta, + updated_at = EXCLUDED.updated_at + """, + ( + record.thread_id, + record.sender_id, + record.active_inquiry_no, + json.dumps(record.interrupt_stack, ensure_ascii=False), + json.dumps(record.meta, ensure_ascii=False), + _utcnow(), + ), + ) + conn.commit() + logger.info("session.pg.upsert thread=%s", record.thread_id) + + def find_by_sender(self, sender_id: str) -> list[SessionRecord]: + with connect_psycopg(self._dsn) as conn: + rows = conn.execute( + """ + SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta + FROM conversation_session WHERE sender_id = %s + """, + (sender_id,), + ).fetchall() + return [ + SessionRecord( + thread_id=str(r[0]), + sender_id=str(r[1]), + active_inquiry_no=str(r[2] or ""), + interrupt_stack=list(r[3] or []), + meta=dict(r[4] or {}), + ) + for r in rows + ] + + +def create_session_store(*, dsn: str = "", backend: str = "auto"): + """ + 工厂:有 DSN 且 backend!=memory → PG;否则 Memory。 + + 框架壳,不自动建业务会话。 + """ + if backend == "memory" or not dsn: + return MemorySessionStore() + return PostgresSessionStore(dsn) diff --git a/inquiry-agent/agent/runtime_db/suspend_store.py b/inquiry-agent/agent/runtime_db/suspend_store.py new file mode 100644 index 0000000..faf3145 --- /dev/null +++ b/inquiry-agent/agent/runtime_db/suspend_store.py @@ -0,0 +1,162 @@ +""" +停等账仓储壳:suspend_wait(waitVersion)。 + +本文件职责:登记/关闭停点;与 Graph interrupt、H5 token 对齐的版本号。 +禁止:用本表冒充主账六态;禁止用户文字伪造 Worker 完成。 +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +from agent.runtime_db import assert_safe_database, connect_psycopg + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class SuspendRecord: + """一条停等记录。""" + + thread_id: str + wait_version: int + pause_name: str = "" + inquiry_no: str = "" + status: str = "open" + payload: dict[str, Any] = field(default_factory=dict) + + +class MemorySuspendStore: + """内存停等壳。""" + + def __init__(self) -> None: + self._rows: dict[tuple[str, int], SuspendRecord] = {} + self._lock = threading.Lock() + + def open_wait(self, record: SuspendRecord) -> None: + key = (record.thread_id, record.wait_version) + with self._lock: + self._rows[key] = SuspendRecord( + thread_id=record.thread_id, + wait_version=record.wait_version, + pause_name=record.pause_name, + inquiry_no=record.inquiry_no, + status="open", + payload=dict(record.payload), + ) + logger.info( + "suspend.memory.open thread=%s wait=%s pause=%s", + record.thread_id, + record.wait_version, + record.pause_name, + ) + + def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]: + with self._lock: + rec = self._rows.get((thread_id, wait_version)) + if rec is None: + return None + return SuspendRecord( + thread_id=rec.thread_id, + wait_version=rec.wait_version, + pause_name=rec.pause_name, + inquiry_no=rec.inquiry_no, + status=rec.status, + payload=dict(rec.payload), + ) + + def close_wait(self, thread_id: str, wait_version: int) -> bool: + with self._lock: + rec = self._rows.get((thread_id, wait_version)) + if rec is None: + return False + rec.status = "closed" + return True + + +class PostgresSuspendStore: + """PG 停等壳(表 suspend_wait)。""" + + def __init__(self, dsn: str) -> None: + assert_safe_database(dsn) + self._dsn = dsn + + def open_wait(self, record: SuspendRecord) -> None: + import json + + with connect_psycopg(self._dsn) as conn: + conn.execute( + """ + INSERT INTO suspend_wait ( + thread_id, wait_version, pause_name, inquiry_no, status, payload + ) VALUES (%s, %s, %s, %s, 'open', %s::jsonb) + ON CONFLICT (thread_id, wait_version) DO UPDATE SET + pause_name = EXCLUDED.pause_name, + inquiry_no = EXCLUDED.inquiry_no, + status = 'open', + payload = EXCLUDED.payload, + closed_at = NULL + """, + ( + record.thread_id, + record.wait_version, + record.pause_name, + record.inquiry_no, + json.dumps(record.payload, ensure_ascii=False), + ), + ) + conn.commit() + logger.info( + "suspend.pg.open thread=%s wait=%s", + record.thread_id, + record.wait_version, + ) + + def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]: + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + """ + SELECT thread_id, wait_version, pause_name, inquiry_no, status, payload + FROM suspend_wait + WHERE thread_id = %s AND wait_version = %s + """, + (thread_id, wait_version), + ).fetchone() + if not row: + return None + return SuspendRecord( + thread_id=str(row[0]), + wait_version=int(row[1]), + pause_name=str(row[2] or ""), + inquiry_no=str(row[3] or ""), + status=str(row[4] or ""), + payload=dict(row[5] or {}), + ) + + def close_wait(self, thread_id: str, wait_version: int) -> bool: + with connect_psycopg(self._dsn) as conn: + cur = conn.execute( + """ + UPDATE suspend_wait + SET status = 'closed', closed_at = %s + WHERE thread_id = %s AND wait_version = %s AND status = 'open' + """, + (_utcnow(), thread_id, wait_version), + ) + conn.commit() + return cur.rowcount > 0 + + +def create_suspend_store(*, dsn: str = "", backend: str = "auto"): + """工厂:Memory 或 PG。""" + if backend == "memory" or not dsn: + return MemorySuspendStore() + return PostgresSuspendStore(dsn) diff --git a/inquiry-agent/agent/schema/__init__.py b/inquiry-agent/agent/schema/__init__.py new file mode 100644 index 0000000..ae869f4 --- /dev/null +++ b/inquiry-agent/agent/schema/__init__.py @@ -0,0 +1,21 @@ +""" +Schema / 字段合同包。 + +校验规则读 contracts JSON;本包不做自然语言抽取。 +""" + +from agent.schema.contracts_loader import ( + contracts_dir, + load_contract, + require_formal_contract, + resolve_contract_path, +) +from agent.schema.field_validate import validate_required_fields_shell + +__all__ = [ + "contracts_dir", + "load_contract", + "require_formal_contract", + "resolve_contract_path", + "validate_required_fields_shell", +] diff --git a/inquiry-agent/agent/schema/contracts_loader.py b/inquiry-agent/agent/schema/contracts_loader.py new file mode 100644 index 0000000..2b0f4c8 --- /dev/null +++ b/inquiry-agent/agent/schema/contracts_loader.py @@ -0,0 +1,97 @@ +""" +字段 / 字典合同加载。 + +本文件职责:从仓库 contracts/ 读 JSON;优先正式文件名,其次 PLACEHOLDER。 +没读到真合同前,业务校验代码不得假装已激活字段规则(prompt/05)。 +""" + +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def contracts_dir() -> Path: + """ + 定位仓库 contracts 目录。 + + 查找顺序:inquiry-agent 上一级 / 再上一级(兼容拷贝到 C:\\ytd)。 + """ + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "contracts" + if candidate.is_dir(): + return candidate + # 回退:与 agent 包同级仓库根的常见布局 + return here.parents[3] / "contracts" + + +def _load_json_file(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def resolve_contract_path(stem: str) -> Optional[Path]: + """ + 解析合同文件路径。 + + stem 例:inquiry-required-fields-v1 / tms-dictionary-projection-v2 + 优先 `{stem}.json`,否则 `{stem}.PLACEHOLDER.json`。 + """ + root = contracts_dir() + formal = root / f"{stem}.json" + if formal.is_file(): + return formal + placeholder = root / f"{stem}.PLACEHOLDER.json" + if placeholder.is_file(): + return placeholder + return None + + +@lru_cache(maxsize=8) +def load_contract(stem: str) -> dict[str, Any]: + """ + 加载合同 JSON。 + + 返回:dict;文件缺失抛 FileNotFoundError。 + is_placeholder:由 meta 或文件名推断,供调用方拒绝「用占位做生产校验」。 + """ + path = resolve_contract_path(stem) + if path is None: + raise FileNotFoundError(f"合同文件不存在: {stem}(目录 {contracts_dir()})") + data = _load_json_file(path) + is_ph = "PLACEHOLDER" in path.name or str(data.get("schemaVersion", "")).endswith( + "placeholder" + ) + data = dict(data) + data["_meta"] = { + "path": str(path), + "is_placeholder": is_ph, + "stem": stem, + } + logger.info( + "已加载合同 stem=%s placeholder=%s path=%s", + stem, + is_ph, + path.name, + ) + return data + + +def require_formal_contract(stem: str) -> dict[str, Any]: + """ + 加载且必须为正式文件(非 PLACEHOLDER)。 + + 骨架联调请用 load_contract;写字段校验业务前必须走本函数。 + """ + data = load_contract(stem) + if data.get("_meta", {}).get("is_placeholder"): + raise RuntimeError( + f"合同仍为占位,禁止做业务校验: {stem}。" + "请将正式 JSON 放到 contracts/(见 _ref/prod-pull 对照)" + ) + return data diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py new file mode 100644 index 0000000..82ecc92 --- /dev/null +++ b/inquiry-agent/agent/schema/field_validate.py @@ -0,0 +1,58 @@ +""" +字段合同校验入口壳。 + +本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。 +禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from agent.schema.contracts_loader import load_contract, require_formal_contract + +logger = logging.getLogger(__name__) + + +def validate_required_fields_shell( + *, + facts: dict[str, Any], + business_line: str = "", + require_formal: bool = True, +) -> dict[str, Any]: + """ + 字段校验空壳。 + + 返回结构固定:ok / implemented / missing / errors。 + implemented=False 表示规则引擎尚未接入(框架阶段预期)。 + """ + stem = "inquiry-required-fields-v1" + try: + contract = ( + require_formal_contract(stem) if require_formal else load_contract(stem) + ) + except (FileNotFoundError, RuntimeError) as exc: + return { + "ok": False, + "implemented": False, + "missing": [], + "errors": [str(exc)], + "business_line": business_line, + } + + is_ph = bool(contract.get("_meta", {}).get("is_placeholder")) + logger.info( + "schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)", + business_line or "-", + is_ph, + sorted(facts.keys()), + ) + return { + "ok": False, + "implemented": False, + "missing": [], + "errors": ["field_activation_rules_not_implemented"], + "business_line": business_line, + "contract_placeholder": is_ph, + } diff --git a/inquiry-agent/agent/storage/__init__.py b/inquiry-agent/agent/storage/__init__.py index 4662cc6..e177d1d 100644 --- a/inquiry-agent/agent/storage/__init__.py +++ b/inquiry-agent/agent/storage/__init__.py @@ -2,5 +2,68 @@ 对象存储:只存字节;元数据登记走主账 attachment。 禁止:在本包维护工单状态。 -骨架:仅占位。 +骨架:S3 兼容客户端工厂与 put/get 占位(未配 endpoint 时不连网)。 """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +@dataclass +class ObjectStorageClient: + """ + 对象存储骨架。 + + 正式用 boto3/minio SDK;本版仅记录配置是否齐全,put_bytes 在未配置时返回 False。 + """ + + endpoint_url: str + bucket: str + access_key: str = "" + secret_key: str = "" + path_style: bool = True + + @classmethod + def from_settings(cls, settings: Optional[Settings] = None) -> "ObjectStorageClient": + cfg = settings or get_settings() + return cls( + endpoint_url=getattr(cfg, "object_storage_endpoint_url", "") or "", + bucket=getattr(cfg, "object_storage_bucket", "") or "", + access_key=getattr(cfg, "object_storage_access_key", "") or "", + secret_key=getattr(cfg, "object_storage_secret_key", "") or "", + path_style=bool(getattr(cfg, "object_storage_path_style", True)), + ) + + def configured(self) -> bool: + """endpoint + bucket 是否已配(不含密钥是否正确)。""" + return bool(self.endpoint_url and self.bucket) + + def put_bytes_stub(self, *, key: str, data: bytes, content_type: str = "") -> bool: + """ + 上传占位:未配置则 False;已配置也仅打日志不真正上传(骨架防误写桶)。 + + 正式实现再打开真实 PUT。 + """ + if not self.configured(): + logger.warning("object_storage 未配置,跳过 put key=%s", key) + return False + logger.info( + "object_storage.put_bytes_stub bucket=%s key=%s bytes=%s(骨架不上传)", + self.bucket, + key, + len(data), + ) + return True + + +# SDK 壳:见 s3_client(默认 dry_run) +from agent.storage.s3_client import S3ObjectStorage # noqa: E402 + +__all__ = ["ObjectStorageClient", "S3ObjectStorage"] diff --git a/inquiry-agent/agent/storage/s3_client.py b/inquiry-agent/agent/storage/s3_client.py new file mode 100644 index 0000000..af80735 --- /dev/null +++ b/inquiry-agent/agent/storage/s3_client.py @@ -0,0 +1,112 @@ +""" +对象存储:S3 兼容客户端壳(boto3)。 + +本文件职责:组装 client、put_bytes / get_bytes;缺依赖或未配齐时明确失败。 +禁止:在本包写工单状态;正式登记附件元数据走主账。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +@dataclass +class S3ObjectStorage: + """ + 基于 boto3 的 S3/MinIO 壳。 + + allow_network=False(默认):不真正 PUT,只校验配置与 SDK 可导入。 + """ + + settings: Settings + allow_network: bool = False + + @classmethod + def from_settings( + cls, + settings: Optional[Settings] = None, + *, + allow_network: bool = False, + ) -> "S3ObjectStorage": + return cls(settings=settings or get_settings(), allow_network=allow_network) + + def _cfg(self) -> tuple[str, str, str, str, bool]: + s = self.settings + return ( + (s.object_storage_endpoint_url or "").rstrip("/"), + s.object_storage_bucket or "", + s.object_storage_access_key or "", + s.object_storage_secret_key or "", + bool(s.object_storage_path_style), + ) + + def configured(self) -> bool: + endpoint, bucket, *_rest = self._cfg() + return bool(endpoint and bucket) + + def sdk_available(self) -> bool: + try: + import boto3 # noqa: F401 + return True + except ImportError: + return False + + def _client(self): + """构造 boto3 client;未配置抛 RuntimeError。""" + import boto3 + from botocore.client import Config + + endpoint, bucket, ak, sk, path_style = self._cfg() + if not endpoint or not bucket or not ak or not sk: + raise RuntimeError("object_storage_not_configured") + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=ak, + aws_secret_access_key=sk, + config=Config(s3={"addressing_style": "path" if path_style else "auto"}), + ) + + def put_bytes(self, *, key: str, data: bytes, content_type: str = "") -> dict: + """ + 上传字节。 + + allow_network=False:返回 dry_run,不访问网络。 + """ + endpoint, bucket, ak, sk, _ps = self._cfg() + if not endpoint or not bucket: + return {"ok": False, "error": "not_configured"} + if not self.sdk_available(): + return {"ok": False, "error": "boto3_not_installed"} + if not self.allow_network: + logger.info( + "storage.s3.put dry_run bucket=%s key=%s bytes=%s", + bucket, + key, + len(data), + ) + return {"ok": True, "dry_run": True, "bucket": bucket, "key": key} + extra = {} + if content_type: + extra["ContentType"] = content_type + self._client().put_object(Bucket=bucket, Key=key, Body=data, **extra) + return {"ok": True, "dry_run": False, "bucket": bucket, "key": key} + + def get_bytes(self, *, key: str) -> dict: + """下载壳;默认 dry_run 不拉网。""" + endpoint, bucket, *_r = self._cfg() + if not endpoint or not bucket: + return {"ok": False, "error": "not_configured"} + if not self.allow_network: + return {"ok": True, "dry_run": True, "key": key, "data": b""} + if not self.sdk_available(): + return {"ok": False, "error": "boto3_not_installed"} + obj = self._client().get_object(Bucket=bucket, Key=key) + body = obj["Body"].read() + return {"ok": True, "dry_run": False, "key": key, "data": body} diff --git a/inquiry-agent/agent/worker_app.py b/inquiry-agent/agent/worker_app.py index 9ffa31d..22dbd15 100644 --- a/inquiry-agent/agent/worker_app.py +++ b/inquiry-agent/agent/worker_app.py @@ -1,8 +1,8 @@ """ 智能体唯一 Worker 入口(LLM / 识别 / 报价 PDF / 存档)。 -本文件职责:组装分槽运行时并阻塞至进程结束;不在此写各业务 Handler。 -禁止:Redis wecom:worker:singleton;禁止五进程拆分;禁止在 HTTP 回调里跑本模块逻辑。 +本文件职责:组装分槽、Graph、Redis、LLM 网关;阻塞至进程结束。 +禁止:Redis wecom:worker:singleton;禁止在 HTTP 回调里跑本模块逻辑。 """ from __future__ import annotations @@ -12,26 +12,34 @@ import signal import threading from agent.config import get_settings +from agent.graph import get_graph_runtime, shutdown_graph_runtime from agent.jobs import WorkerRuntime +from agent.llm import LlmGateway +from agent.redis_coord import get_redis_runtime, shutdown_redis_runtime logger = logging.getLogger(__name__) def main() -> None: """ - 启动 Worker 槽与 inbox/outbox 占位线程,主线程等待停止信号。 + 启动 Worker:wake Stream + LLM Stream + LibreOffice 槽。 - 副作用:创建后台线程;骨架不消费真实队列、不连库。 - 并发:LibreOffice 1 槽;HTTP 类至少 2~3 槽;与 inbox/outbox 隔离。 + Graph resume/wake 仅走 http_jobs 槽。 """ logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", ) settings = get_settings() + graph_runtime = get_graph_runtime(settings) + redis_runtime = get_redis_runtime(settings) + llm = LlmGateway.from_settings(settings) runtime = WorkerRuntime.from_settings( - libreoffice_slots=settings.libreoffice_slots, - http_job_slots=settings.http_job_slots, + settings.libreoffice_slots, + settings.http_job_slots, + graph=graph_runtime, + redis=redis_runtime, + llm=llm, ) runtime.start() @@ -40,12 +48,19 @@ def main() -> None: def _handle_signal(signum: int, _frame: object) -> None: logger.info("收到停止信号 signum=%s", signum) runtime.stop() + shutdown_graph_runtime() + shutdown_redis_runtime() stop_event.set() signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) - logger.info("Worker 骨架运行中 ytd_env=%s(Ctrl+C 退出)", settings.ytd_env) + logger.info( + "Worker 运行中 ytd_env=%s checkpoint=%s redis=%s(Ctrl+C 退出)", + settings.ytd_env, + graph_runtime.checkpoint.backend, + redis_runtime.client.backend, + ) stop_event.wait() logger.info("Worker 已退出") diff --git a/inquiry-agent/migrations/001_runtime_core_v1.sql b/inquiry-agent/migrations/001_runtime_core_v1.sql new file mode 100644 index 0000000..7e3889e --- /dev/null +++ b/inquiry-agent/migrations/001_runtime_core_v1.sql @@ -0,0 +1,125 @@ +-- 001_runtime_core_v1.sql +-- 新系统运行账核心表(inquiry_robot_runtime) +-- 禁止对现网 ytd_runtime 执行。 +-- Graph checkpoint 表由 LangGraph PostgresSaver.setup() 另建,不在此脚本。 + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + description TEXT NOT NULL DEFAULT '' +); + +-- 企微入站 inbox:回调只写入;Worker/通道线程认领 +CREATE TABLE IF NOT EXISTS agent_inbox ( + id UUID PRIMARY KEY, + message_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + chat_id TEXT NOT NULL DEFAULT '', + chat_type TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + claimed_at TIMESTAMPTZ, + claimed_by TEXT NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT agent_inbox_message_id_uq UNIQUE (message_id) +); + +CREATE INDEX IF NOT EXISTS idx_agent_inbox_state_created + ON agent_inbox (state, created_at); + +-- 企微出站 outbox:发送成功 ≠ 业务成功;过期可丢弃 +CREATE TABLE IF NOT EXISTS agent_outbox ( + id UUID PRIMARY KEY, + dedupe_key TEXT NOT NULL DEFAULT '', + touser TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + msg_type TEXT NOT NULL DEFAULT 'text', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + state TEXT NOT NULL DEFAULT 'pending', + claimed_at TIMESTAMPTZ, + claimed_by TEXT NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + max_age_seconds INT NOT NULL DEFAULT 86400, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_outbox_dedupe + ON agent_outbox (dedupe_key) + WHERE dedupe_key <> ''; + +CREATE INDEX IF NOT EXISTS idx_agent_outbox_state_created + ON agent_outbox (state, created_at); + +-- 会话:活动工单与打断栈(非六态真相) +CREATE TABLE IF NOT EXISTS conversation_session ( + thread_id TEXT PRIMARY KEY, + sender_id TEXT NOT NULL, + active_inquiry_no TEXT NOT NULL DEFAULT '', + interrupt_stack JSONB NOT NULL DEFAULT '[]'::jsonb, + meta JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_conversation_session_sender + ON conversation_session (sender_id); + +-- 停等账:waitVersion(与卡片/H5 绑定) +CREATE TABLE IF NOT EXISTS suspend_wait ( + thread_id TEXT NOT NULL, + wait_version INT NOT NULL, + pause_name TEXT NOT NULL DEFAULT '', + inquiry_no TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + PRIMARY KEY (thread_id, wait_version) +); + +-- 补问 / 卡片 token(H5 TTL 约 30 分钟由应用写入 expires_at) +CREATE TABLE IF NOT EXISTS clarification_binding ( + token TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + wait_version INT NOT NULL, + sender_id TEXT NOT NULL DEFAULT '', + allowed_actions JSONB NOT NULL DEFAULT '[]'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_clarification_thread_wait + ON clarification_binding (thread_id, wait_version); + +-- 可选:PG 任务认领表(与 Redis Stream 二选一做真相;骨架两套都建好,业务接入时只留一条) +CREATE TABLE IF NOT EXISTS agent_job ( + id UUID PRIMARY KEY, + job_type TEXT NOT NULL, + idempotency_key TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + claimed_at TIMESTAMPTZ, + claimed_by TEXT NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + result JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_job_idem + ON agent_job (job_type, idempotency_key) + WHERE idempotency_key <> ''; + +CREATE INDEX IF NOT EXISTS idx_agent_job_state_created + ON agent_job (state, created_at); + +INSERT INTO schema_migrations (version, description) +VALUES ('001_runtime_core_v1', 'inbox/outbox/session/suspend/clarification/job 核心表') +ON CONFLICT (version) DO NOTHING; diff --git a/inquiry-agent/migrations/002_dead_letter_v1.sql b/inquiry-agent/migrations/002_dead_letter_v1.sql new file mode 100644 index 0000000..59ab520 --- /dev/null +++ b/inquiry-agent/migrations/002_dead_letter_v1.sql @@ -0,0 +1,25 @@ +-- 002_dead_letter_v1.sql +-- 死信表:失败任务落点(框架壳,不含业务重放逻辑) +-- 禁止对现网 ytd_runtime 执行。 + +CREATE TABLE IF NOT EXISTS agent_dead_letter ( + id UUID PRIMARY KEY, + source TEXT NOT NULL DEFAULT '', + source_id TEXT NOT NULL DEFAULT '', + job_type TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + replayed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_agent_dead_letter_created + ON agent_dead_letter (created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_agent_dead_letter_job_type + ON agent_dead_letter (job_type, created_at DESC); + +INSERT INTO schema_migrations (version, description) +VALUES ('002_dead_letter_v1', 'agent_dead_letter 死信表壳') +ON CONFLICT (version) DO NOTHING; diff --git a/inquiry-agent/migrations/README.md b/inquiry-agent/migrations/README.md index 99a40ea..310607c 100644 --- a/inquiry-agent/migrations/README.md +++ b/inquiry-agent/migrations/README.md @@ -1,7 +1,24 @@ # PostgreSQL 运行账迁移 -后续在此目录添加版本化 SQL(inbox / outbox / checkpoint / 任务认领等)。 +- 只对当前环境变量 `PG_DATABASE` / `PG_DSN` 执行(新系统 **`inquiry_robot_runtime`**)。 +- **禁止**对现网 `ytd_runtime` 执行迁移(代码会拒绝)。 -- 只对当前环境变量 `PG_DATABASE` 执行(新系统 `inquiry_robot_runtime`)。 -- **禁止**对现网 `ytd_runtime` 执行迁移。 -- 骨架阶段不建业务表。 +## 文件 + +| 文件 | 内容 | +|---|---| +| `001_runtime_core_v1.sql` | inbox / outbox / conversation_session / suspend_wait / clarification_binding / agent_job | + +## Graph checkpoint + +LangGraph `PostgresSaver.setup()` 会在连接库内另建 checkpoint 表,与上表分开。 + +## 怎么跑 + +```text +cd inquiry-agent +python -m agent.runtime_db.migrate --list +python -m agent.runtime_db.migrate +``` + +需已配置 `PG_DSN` 或 `PG_HOST`+`PG_DATABASE`+`PG_USER`(及密码)。 diff --git a/inquiry-agent/requirements.txt b/inquiry-agent/requirements.txt index ea2222c..45c7ab6 100644 --- a/inquiry-agent/requirements.txt +++ b/inquiry-agent/requirements.txt @@ -1,4 +1,4 @@ -# Python agent dependencies (ASCII only for Windows GBK pip) +# Python agent dependencies (ASCII only; avoid Windows GBK pip decode errors) fastapi>=0.115.0,<1.0 uvicorn[standard]>=0.32.0,<1.0 httpx>=0.27.0,<1.0 @@ -6,3 +6,11 @@ python-dotenv>=1.0.0,<2.0 pydantic>=2.0,<3.0 pydantic-settings>=2.0,<3.0 cryptography>=42,<45 +# LangGraph orchestration (checkpoint in PG) +langgraph>=1.0,<1.3 +langgraph-checkpoint>=2.0,<5.0 +langgraph-checkpoint-postgres>=2.0,<4.0 +psycopg[binary]>=3.1,<4.0 +psycopg-pool>=3.2,<4.0 +# Redis short-lived coordination only +redis>=5.0,<6.0 diff --git a/inquiry-agent/resources/quote_templates/README.md b/inquiry-agent/resources/quote_templates/README.md new file mode 100644 index 0000000..b0e3c7a --- /dev/null +++ b/inquiry-agent/resources/quote_templates/README.md @@ -0,0 +1,6 @@ +# 报价模板本地缓存(可选,非生产真相) + +生产流程:管理后台上传/启用/停用 → 主账元数据 + 对象存储字节。 +智能体生成时经主账取已启用模板,再下载对象;**不要**把本目录当模板源。 + +本目录仅可作开发机临时缓存,可空。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java new file mode 100644 index 0000000..0bf78be --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java @@ -0,0 +1,67 @@ +package org.jeecg.modules.inquiry.tms; + +/** + * TMS 出站客户端壳。 + *

+ * 职责:将来封装 token(client_credentials)、查询 Bearer、锁舱 HMAC。 + * 当前:所有方法标记未实现,避免半可用打到真实 TMS。 + * 禁止:在智能体侧复制本类去直连 TMS。 + */ +public class TmsAdapterClient { + + /** + * 探测配置是否齐全(不访问外网)。 + * + * @return true 表示环境变量/配置看起来可组装;不代表 TMS 可达 + */ + public boolean isConfigured() { + // 框架壳:正式从 Environment 读 TMS_V2_*;此处固定 false 防误用 + return false; + } + + /** + * 健康检查壳:未实现。 + * + * @return 永远失败,带 not_implemented + */ + public TmsShellResult health() { + return TmsShellResult.notImplemented("health"); + } + + /** + * 查询报价壳:未实现。 + * + * @param requestBody 查询体(不解析) + * @return not_implemented + */ + public TmsShellResult query(String requestBody) { + return TmsShellResult.notImplemented("query"); + } + + /** + * 字典同步壳:未实现。 + * + * @return not_implemented + */ + public TmsShellResult syncDictionary() { + return TmsShellResult.notImplemented("syncDictionary"); + } + + /** + * 锁舱壳:默认关闭;未实现。 + * + * @return not_implemented + */ + public TmsShellResult lockCabin(String requestBody) { + return TmsShellResult.notImplemented("lockCabin"); + } + + /** + * 释放舱壳:默认关闭;未实现。 + * + * @return not_implemented + */ + public TmsShellResult releaseCabin(String requestBody) { + return TmsShellResult.notImplemented("releaseCabin"); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java new file mode 100644 index 0000000..2719d4e --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java @@ -0,0 +1,45 @@ +package org.jeecg.modules.inquiry.tms; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.jeecg.common.api.vo.Result; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * TMS 适配 HTTP 壳。 + *

+ * 路径:/inquiry/tms/*;当前全部 501/未实现,供后台与联调探测包是否挂载。 + * 禁止:在本控制器同步等 Python Graph。 + */ +@Api(tags = "询价-TMS适配壳") +@RestController +@RequestMapping("/inquiry/tms") +public class TmsAdapterController { + + private final TmsAdapterClient client = new TmsAdapterClient(); + + /** + * 健康壳:返回未实现说明,不打真实 TMS。 + * + * @return Result 包装 + */ + @ApiOperation("TMS 健康壳(未实现)") + @GetMapping("/health") + public Result health() { + TmsShellResult r = client.health(); + return Result.error(r.getHttpStatus(), "TMS adapter shell: " + r.getError() + ":" + r.getOperation()); + } + + /** + * 配置探测:是否已配齐(壳阶段恒 false)。 + * + * @return configured=false + */ + @ApiOperation("TMS 配置探测壳") + @GetMapping("/configured") + public Result configured() { + return Result.OK(java.util.Collections.singletonMap("configured", client.isConfigured())); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsShellResult.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsShellResult.java new file mode 100644 index 0000000..af978c7 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsShellResult.java @@ -0,0 +1,47 @@ +package org.jeecg.modules.inquiry.tms; + +/** + * TMS 适配空结果。 + *

+ * 不含业务码映射;正式实现再区分 1002/1003/技术失败。 + */ +public final class TmsShellResult { + + private final boolean ok; + private final int httpStatus; + private final String error; + private final String operation; + + private TmsShellResult(boolean ok, int httpStatus, String error, String operation) { + this.ok = ok; + this.httpStatus = httpStatus; + this.error = error; + this.operation = operation; + } + + /** + * 构造未实现结果(HTTP 501 语义)。 + * + * @param operation 操作名 + * @return 结果对象 + */ + public static TmsShellResult notImplemented(String operation) { + return new TmsShellResult(false, 501, "not_implemented", operation); + } + + public boolean isOk() { + return ok; + } + + public int getHttpStatus() { + return httpStatus; + } + + public String getError() { + return error; + } + + public String getOperation() { + return operation; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java new file mode 100644 index 0000000..5c64b5b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java @@ -0,0 +1,9 @@ +/** + * TMS 适配空包:智能体不直连 TMS,仅主账本包出站。 + *

+ * 本包当前只提供健康探测与未实现占位;token / query / 字典 sync / 锁舱 + * 正式实现前一律返回未启用,禁止半可用 LOCK/RELEASE。 + *

+ * 禁止:按企微 userid 伪造 TMS 结果;禁止模糊字典匹配。 + */ +package org.jeecg.modules.inquiry.tms; diff --git a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml index 42cfcab..56eb9bb 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml +++ b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml @@ -82,6 +82,7 @@ spring: database: ${YTD_REDIS_DB:1} host: ${YTD_REDIS_HOST:10.206.0.14} port: ${YTD_REDIS_PORT:6379} + username: ${YTD_REDIS_USERNAME:} password: ${YTD_REDIS_PASSWORD:} mybatis-plus: diff --git a/inquiry-backend/.env.development b/inquiry-backend/.env.development new file mode 100644 index 0000000..52564a0 --- /dev/null +++ b/inquiry-backend/.env.development @@ -0,0 +1,23 @@ +# 是否打开mock +VITE_USE_MOCK = false + +# 发布路径 +VITE_PUBLIC_PATH = / + +# 友通达:代理到新主账 8180 + context-path /jeecgboot(与 application-inquiry.yml 一致) +VITE_PROXY = [["/jeecgboot","http://localhost:8180/jeecgboot"],["/upload","http://localhost:8180/jeecgboot/sys/common/upload"]] + +# 控制台不输出 +VITE_DROP_CONSOLE = false + +#后台接口父地址(必填) +VITE_GLOB_API_URL=/jeecgboot + +#后台接口全路径地址(必填) +VITE_GLOB_DOMAIN_URL=http://localhost:8180/jeecgboot + +# 接口前缀 +VITE_GLOB_API_URL_PREFIX= + +#微前端qiankun应用,命名必须以VITE_APP_SUB_开头,jeecg-app-1为子应用的项目名称,也是子应用的路由父路径 +VITE_APP_SUB_jeecg-app-1 = '//localhost:8092' diff --git a/inquiry-backend/.env.production b/inquiry-backend/.env.production new file mode 100644 index 0000000..8168261 --- /dev/null +++ b/inquiry-backend/.env.production @@ -0,0 +1,34 @@ +# 是否启用mock(生产必须 false,否则会走本地 Mock 像旧骨架) +VITE_USE_MOCK = false + +# 发布路径 +VITE_PUBLIC_PATH = / + +# 控制台不输出 +VITE_DROP_CONSOLE = true + +# 是否启用gzip或brotli压缩 +# 选项值: gzip | brotli | none +# 如果需要多个可以使用“,”分隔 +VITE_BUILD_COMPRESS = 'gzip' + +# 使用压缩时是否删除原始文件,默认为false +VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false + +#后台接口父地址(必填) — 同域反代到新主账 8180 +VITE_GLOB_API_URL=/jeecgboot + +#后台接口全路径地址(必填) — 正式域名,禁止写死 Docker 内网主机名 +VITE_GLOB_DOMAIN_URL=https://ai.ytd-scm.com + +# 接口父路径前缀 +VITE_GLOB_API_URL_PREFIX= + +# 是否启用图像压缩 +VITE_USE_IMAGEMIN= true + +# 使用pwa +VITE_USE_PWA = false + +# 是否兼容旧浏览器 +VITE_LEGACY = false diff --git a/inquiry-backend/src/views/inquiry/login/index.vue b/inquiry-backend/src/views/inquiry/login/index.vue index a5c0755..21c368b 100644 --- a/inquiry-backend/src/views/inquiry/login/index.vue +++ b/inquiry-backend/src/views/inquiry/login/index.vue @@ -79,7 +79,6 @@ 登录 -

diff --git a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css index 98da851..1733861 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css @@ -1186,14 +1186,6 @@ textarea:focus-visible { color: #667085; } -.login-hint { - margin-top: 12px; - font-size: 10px; - font-weight: 600; - color: #98a2b3; - text-align: center; -} - .btn { display: inline-flex; align-items: center; diff --git a/inquiry-backend/vite.config.ts b/inquiry-backend/vite.config.ts index f5aebb6..9b4faf2 100644 --- a/inquiry-backend/vite.config.ts +++ b/inquiry-backend/vite.config.ts @@ -64,7 +64,8 @@ export default ({ command, mode }: ConfigEnv): UserConfig => { }, build: { minify: 'esbuild', - target: 'es2015', + // es2015 无法转译部分依赖里的 async generator,生产构建会失败;与 optimizeDeps 对齐 es2020 + target: 'es2020', cssTarget: 'chrome80', outDir: OUTPUT_DIR, terserOptions: { diff --git a/prompt/00-INDEX.md b/prompt/00-INDEX.md index d195ab4..e293f2a 100644 --- a/prompt/00-INDEX.md +++ b/prompt/00-INDEX.md @@ -6,7 +6,7 @@ |---|---| | `01-现状总览.md` | 语言、框架、数据库、部署、域名、现网端口、目标进程 | | `02-各端职责.md` | 每个端/进程负责什么(含新系统合并) | -| `03-配置与密钥清单.md` | 配置项全表 + 生产明文 | +| `03-配置与密钥清单.md` | 新系统配置路径 + 可复用密钥;现网路径只作只读对照 | | `04-智能体与模型配置.md` | DeepSeek / 千问角色、Graph、字段合同 | | `05-总控提示词.md` | 给新项目的总提示词(含库表开发期直接改) | | `06-Python智能体.md` | 智能体/Worker 端提示词 |