Compare commits

...

No commits in common. "main" and "graph_memory" have entirely different histories.

559 changed files with 3309 additions and 38535 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.env
__pycache__/
data/
agent_memory/
test_knowledge_base/

View File

@ -92,12 +92,12 @@ python .\main_cli.py
## 运行 Web UI
当前 Web 前端源码已经内置在本项目的 `frontend_nanobot/` 中,构建后的静态文件统一输出到 `webui/dist/`,由 `webui_server.py` 直接托管,不再依赖外部 `nanobot` 目录。
当前 Web 前端源码已经收口到本项目的 `webui/frontend/` 中,构建后的静态文件统一输出到 `webui/dist/`,由 `webui_server.py` 直接托管,不再依赖单独的 `nanobot` 目录。
### 1. 安装前端依赖
```powershell
cd .\frontend_nanobot
cd .\webui\frontend
npm install
```
@ -116,7 +116,6 @@ npm run build
### 3. 启动 Web 网关
```powershell
cd ..
python .\webui_server.py
```
@ -143,23 +142,9 @@ python .\main_cli.py --offline
单条离线导入测试:
```powershell
python .\main_cli.py --offline --once "导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt 知识库 合川分公司"
python .\main_cli.py --offline --once "导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt"
```
## 知识库选择约定
当前只是演示 demo不做团队隔离。`data/` 下每个子文件夹视作一个独立知识库,例如:
```text
data/
├─ 合川分公司/
└─ 另一个知识库/
```
未来 Web 界面可以列出这些文件夹,让用户选择一个知识库后,把文件夹名作为 `knowledge_base_id` 传给工具进行针对性提问。`team_id` 只是旧兼容参数,后续推荐使用 `knowledge_base_id`
在当前这版 Web 里,系统会把每个知识库自动映射成一个可进入的会话入口;选中对应会话后提问,就会优先基于该知识库回答。
## Agent 工具
- `current_time`:获取当前时间。
@ -170,11 +155,10 @@ data/
- `execute_shell`:执行 shell 命令做环境检查或调试。
- `run_python`:执行简短 Python 代码做快速处理。
- `tool_trace_query`:按需查询本地保存的工具调用轨迹。
- `import_meeting_transcript`:导入会议原文,交给会议整理子 Agent 做项目化整理,并更新指定知识库的 Markdown 台账。
- `query_knowledge`:查询指定知识库周会台账。
- `list_knowledge_bases`:列出 `data/` 下已有知识库。
- `store_meeting_memory`:把会议原文写入长期会议记忆,执行抽取、归档、状态合并与图谱索引。
- `query_meeting_memory`:查询长期会议记忆中的会议上下文、实体关系、行动项和指标信息。
LLM Agent 会在对话中自动选择这些工具。会议整理本身由 `prompt/zh/meeting_digest.yaml` 驱动的子 Agent 完成,主 Agent 只负责调工具和查询台账
LLM Agent 会在对话中自动选择这些工具。当前知识功能已经切换到图谱化的 `meeting_memory` 管线,不再维护旧版 Markdown 台账知识库
## 多轮会话机制
@ -185,7 +169,7 @@ LLM Agent 会在对话中自动选择这些工具。会议整理本身由 `promp
- 工具调用的参数、思考、结果会单独保存到本地 `tool_trace.json`
- 默认不会把大量工具过程长期塞回主上下文;需要时由 Agent 调用 `tool_trace_query` 再查。
CLI 默认使用会话 `cli_default`。Web 侧每个聊天或知识库会映射到独立的本地会话目录。
CLI 默认使用会话 `cli_default`。Web 侧每个聊天会映射到独立的本地会话目录。
## 输出数据
@ -193,10 +177,8 @@ CLI 默认使用会话 `cli_default`。Web 侧每个聊天或知识库会映射
```text
data/
└─ 合川分公司/ # 一个知识库示例
├─ state.json
├─ team_ledger.md
└─ meetings/
├─ meeting_state.json
└─ raw/
```
`team_ledger.md` 是面向人和 Agent 共同读取的主文档;`state.json` 和 `meetings/*.json` 是可复用的结构化中间结果。

View File

@ -1,14 +0,0 @@
{
"session_id": "chat_2b0479c17171",
"messages": [
{
"role": "user",
"content": "这个知识库想帮我清空一下内容"
},
{
"role": "assistant",
"content": "thought\n“会议测试”知识库的内容已成功清空\n\n1. **项目台账** (`team_ledger.md`):已重置为初始状态。\n2. **会议记录** (`meetings/` 目录):已清空。\n3. **状态文件** (`state.json`):已重置为空对象。\n\n现在该知识库已处于干净的状态可以开始导入新的会议内容。"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -1,14 +0,0 @@
{
"session_id": "chat_380095ce5521",
"messages": [
{
"role": "user",
"content": "为啥我的知识库没了?你检查一下呢"
},
{
"role": "assistant",
"content": "非常抱歉,我深入检查后发现:\n\n虽然 `data/` 目录下确实存在一个名为 **“会议测试”** 的文件夹,但这个文件夹目前是**空的**(里面没有任何文件,也没有项目台账 Markdown。\n\n**目前的结论是:**\n1. **文件夹结构还在**:系统识别到了“会议测试”这个库。\n2. **内容确实丢失了**:该库下没有存放任何会议记录或项目台账。\n\n**建议下一步操作:**\n* 如果您之前导入过会议请检查一下原始的转录文件ASR文本是否还在您的本地电脑上如果有您可以**重新调用导入功能**,我会重新为您生成并维护台账。\n* 如果您记得之前操作过其他知识库,请告诉我名字,我再帮您全局搜索一下。"
}
],
"max_persisted_turns": 20
}

View File

@ -1,61 +0,0 @@
{
"max_turns": 20,
"next_turn_id": 3,
"active_turn": {
"turn_id": 2,
"started_at": "2026-06-24T11:59:08.318523",
"completed_at": "",
"user_message": "帮我把这个会议添加进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_380095ce5521\\1782273548315_1_transcript_704.md",
"assistant_final": "",
"tool_calls": []
},
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T11:58:16.700065",
"completed_at": "2026-06-24T11:58:42.343354",
"user_message": "为啥我的知识库没了?你检查一下呢",
"assistant_final": "非常抱歉,我深入检查后发现:\n\n虽然 `data/` 目录下确实存在一个名为 **“会议测试”** 的文件夹,但这个文件夹目前是**空的**(里面没有任何文件,也没有项目台账 Markdown。\n\n**目前的结论是:**\n1. **文件夹结构还在**:系统识别到了“会议测试”这个库。\n2. **内容确实丢失了**:该库下没有存放任何会议记录或项目台账。\n\n**建议下一步操作:**\n* 如果您之前导入过会议请检查一下原始的转录文件ASR文本是否还在您的本地电脑上如果有您可以**重新调用导入功能**,我会重新为您生成并维护台账。\n* 如果您记得之前操作过其他知识库,请告诉我名字,我再帮您全局搜索一下。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user is asking \"Why is my knowledge base gone? Can you check it?\"\nThis implies the user expects to see knowledge bases that were previously there, but they are currently missing or they can't find them.\n\nTo investigate this, I should:\n1. List the available knowledge bases using `list_knowledge_bases`.\n2. Check the `data/` directory directly using `list_directory` to see if there are any folders that `list_knowledge_bases` might have missed or if the directory structure is empty.\n\nLet's start by calling `list_knowledge_bases`.",
"result": "{\n \"knowledge_bases\": [],\n \"count\": 0\n}",
"tool_call_id": "chatcmpl-tool-9d0e8831de348787",
"created_at": "2026-06-24T11:58:22.291269"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data"
},
"thought": "",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\",\n \"entries\": [\n {\n \"name\": \"会议测试\",\n \"path\": \"data\\\\会议测试\",\n \"type\": \"directory\",\n \"size\": null,\n \"modified_at\": \"2026-06-24T11:57:20\"\n }\n ]\n}",
"tool_call_id": "chatcmpl-tool-82afe91230ca6ba5",
"created_at": "2026-06-24T11:58:23.952764"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data/会议测试"
},
"thought": "The user is asking why their knowledge base is gone.\nI previously called `list_knowledge_bases` which returned an empty list.\nThen I called `list_directory` on `data/` and found one directory: `data\\会议测试`.\nWait, `list_knowledge_bases` returned `{\"knowledge_bases\": [], \"count\": 0}` but `list_directory` on `data/` returned `{\"success\": true, \"path\": \"...\", \"entries\": [{\"name\": \"会议测试\", ...}]}`.\n\nThis is a discrepancy. `list_knowledge_bases` should ideally return what's in `data/`.\nLet's check why `list_knowledge_bases` might have returned empty.\nMaybe `list_knowledge_bases` implementation looks for specific patterns or is bugged, or maybe the environment is different.\nActually, looking at the tool output, `list_knowledge_bases` returned `[]`.\nBut `list_directory` on `data` shows `会议测试`.\n\nI should explain to the user that I found a directory named `会议测试` in the `data` folder, which might be their knowledge base, even though the \"list_knowledge_bases\" command initially didn't show it.\n\nWait, I should verify if `会议测试` is actually a valid knowledge base by trying to query it or looking inside.\nLet's try `query_knowledge` with `knowledge_base_id=\"会议测试\"` to see if it works. Or just list files inside `data/会议测试`.\n\nFirst, I will inform the user what I found.",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\会议测试\",\n \"entries\": []\n}",
"tool_call_id": "chatcmpl-tool-b0fa76520ddbddb5",
"created_at": "2026-06-24T11:58:36.450296"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data/会议测试"
},
"thought": "",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\会议测试\",\n \"entries\": []\n}",
"tool_call_id": "chatcmpl-tool-a90dc4853426e28f",
"created_at": "2026-06-24T11:58:37.155216"
}
]
}
]
}

View File

@ -1,14 +0,0 @@
{
"session_id": "chat_4010afc8b71d",
"messages": [
{
"role": "user",
"content": "帮我将这个会议整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md"
},
{
"role": "assistant",
"content": "I 检查了当前的知识库列表,发现目前还没有建立任何知识库。\n\n为了能顺利将这次会议内容整理进去请告诉我\n1. 你想为这个会议创建一个什么样的**知识库名称**(例如:`研发项目组` 或 `市场部`\n2. 或者,如果你已经有现成的知识库,请告知我它的名称。\n\n一旦确定了知识库名称我就会立即调用导入工具将 `transcript_704.md` 的内容整理到该知识库的项目台账中。"
}
],
"max_persisted_turns": 20
}

View File

@ -1,31 +0,0 @@
{
"max_turns": 20,
"next_turn_id": 3,
"active_turn": {
"turn_id": 2,
"started_at": "2026-06-24T13:46:34.094819",
"completed_at": "",
"user_message": "会议测试",
"assistant_final": "",
"tool_calls": []
},
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T13:46:06.228573",
"completed_at": "2026-06-24T13:46:25.086646",
"user_message": "帮我将这个会议整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md",
"assistant_final": "I 检查了当前的知识库列表,发现目前还没有建立任何知识库。\n\n为了能顺利将这次会议内容整理进去请告诉我\n1. 你想为这个会议创建一个什么样的**知识库名称**(例如:`研发项目组` 或 `市场部`\n2. 或者,如果你已经有现成的知识库,请告知我它的名称。\n\n一旦确定了知识库名称我就会立即调用导入工具将 `transcript_704.md` 的内容整理到该知识库的项目台账中。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user wants to import a meeting transcript into a knowledge base.\nThe transcript file path is provided: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md`.\nThe user hasn't specified a `knowledge_base_id`.\nAccording to the system instructions, I should first check if there are any existing knowledge bases.\nIf there is only one knowledge base, the tool `import_meeting_transcript` will automatically use it.\nIf there are multiple, I should ask the user to specify which one.\n\nFirst step: List knowledge bases to see what's available.",
"result": "{\n \"knowledge_bases\": [],\n \"count\": 0\n}",
"tool_call_id": "chatcmpl-tool-a58495f5f3ab062e",
"created_at": "2026-06-24T13:46:12.611470"
}
]
}
]
}

View File

@ -1,22 +0,0 @@
{
"session_id": "chat_8b877198aabf",
"messages": [
{
"role": "user",
"content": "帮我将这个会议创建一个知识库储存\n\n附加文本文件已保存可直接读取或导入\n- transcript_710.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_8b877198aabf\\1782281323021_1_transcript_710.md"
},
{
"role": "assistant",
"content": "已成功为您创建知识库 `meeting_710` 并完成了会议内容的导入。\n\n**处理结果:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议摘要**本次会议主要围绕云桌面业务的渠道赋能、AI 产品转型、售后体系建设、产品方案完整性及销售售前协作五个核心维度进行了深度复盘。\n- **统计数据**\n - 识别到项目5 个\n - 待办事项 (Actions)4 项\n - 风险点 (Risks)2 项\n - 追踪项 (Follow-ups)4 项\n\n您现在可以针对该知识库询问具体的项目进度、待办事项或风险详情。例如“查询 meeting_710 中关于 AI 转型的待办事项”。"
},
{
"role": "user",
"content": "我现在有哪些代办事项?"
},
{
"role": "assistant",
"content": "在 `meeting_710` 知识库中,共识别到以下 **4 项** 待办事项:\n\n| 事项 | 责任对象 | 当前状态 | 下一步动作 | 所属项目 |\n| :--- | :--- | :--- | :--- | :--- |\n| **职责划分细化** | 销售/售前管理层 | 待跟进 | 组织销售与售前座谈会,明确职责边界 | 销售与售前协作流程优化 |\n| **联络官人选评估** | 售前团队 | 待跟进 | 评估合适人员并提交建议 | 省代赋能与渠道管理 |\n| **项目分级制度建立** | 售后团队 | 进行中 | 明确重点与非重点项目的服务边界 | 售后服务体系建设 |\n| **Next X 市场机会研究** | 产品/销售团队 | 待跟进 | 提交 Next X 怎么卖及市场机会报告 | AI 云桌面 (智听云/Next X) 研发 |\n\n**台账路径**`data/meeting_710/team_ledger.md`"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -1,22 +0,0 @@
{
"session_id": "cli_default",
"messages": [
{
"role": "user",
"content": "测试测试测试"
},
{
"role": "assistant",
"content": "系统正常运行 ✅\n\n目前 `data/` 下已有 **1 个知识库**\n- 📁 **会议测试**`data\\会议测试`\n\n我可以帮你做以下事情\n\n| 功能 | 说明 |\n|------|------|\n| 📥 导入会议转录 | 传入 ASR 转录文件,自动抽取指标、待办、风险,更新周会台账 |\n| 🔍 查询知识库 | 查指标、待办、风险、完成情况、追踪项等 |\n| 📋 列知识库列表 | 查看所有可用的知识库 |\n| 📂 文件浏览 | 查看工作区文件结构 |\n\n有什么需要我帮你处理的吗比如导入一份会议记录或者查询某个台账内容 😊"
},
{
"role": "user",
"content": "测试测试"
},
{
"role": "assistant",
"content": "收到,测试通过!✅\n\n目前我已准备就绪。如果你想开始正式使用可以尝试\n\n1. **导入数据**`import_meeting_transcript(file_path=\"path/to/your/asr.txt\", knowledge_base_id=\"会议测试\")`\n2. **查询内容**`query_knowledge(query=\"最近有哪些待办事项?\", knowledge_base_id=\"会议测试\")`\n\n请随时下达指令。 😊"
}
],
"max_persisted_turns": 20
}

View File

@ -1,58 +0,0 @@
{
"max_turns": 20,
"next_turn_id": 7,
"active_turn": null,
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-23T17:18:38.118377",
"completed_at": "2026-06-23T17:18:42.122584",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Connection error.\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.app/v1。",
"tool_calls": []
},
{
"turn_id": 2,
"started_at": "2026-06-23T17:19:58.153667",
"completed_at": "2026-06-23T17:20:00.194734",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Error code: 404 - {'success': False, 'data': None, 'error': {'code': 'NOT_FOUND', 'msg': 'route not found'}}\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.ai/v1/responses。",
"tool_calls": []
},
{
"turn_id": 3,
"started_at": "2026-06-23T17:20:21.992077",
"completed_at": "2026-06-23T17:20:24.667988",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Error code: 404 - {'success': False, 'data': None, 'error': {'code': 'NOT_FOUND', 'msg': 'route not found'}}\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.ai/v1。",
"tool_calls": []
},
{
"turn_id": 4,
"started_at": "2026-06-23T17:27:42.969133",
"completed_at": "2026-06-23T17:27:51.053781",
"user_message": "测试测试测试",
"assistant_final": "系统正常运行 ✅\n\n目前 `data/` 下已有 **1 个知识库**\n- 📁 **会议测试**`data\\会议测试`\n\n我可以帮你做以下事情\n\n| 功能 | 说明 |\n|------|------|\n| 📥 导入会议转录 | 传入 ASR 转录文件,自动抽取指标、待办、风险,更新周会台账 |\n| 🔍 查询知识库 | 查指标、待办、风险、完成情况、追踪项等 |\n| 📋 列知识库列表 | 查看所有可用的知识库 |\n| 📂 文件浏览 | 查看工作区文件结构 |\n\n有什么需要我帮你处理的吗比如导入一份会议记录或者查询某个台账内容 😊",
"tool_calls": [
{
"tool_name": "list_directory",
"arguments": {
"path": "data"
},
"thought": "用户说\"测试测试测试\",这看起来像是测试对话。我需要先看看当前工作区的状态,了解 data/ 下有哪些知识库。让我先看看目录结构。",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\",\n \"entries\": [\n {\n \"name\": \"会议测试\",\n \"path\": \"data\\\\会议测试\",\n \"type\": \"directory\",\n \"size\": null,\n \"modified_at\": \"2026-06-23T16:33:42\"\n }\n ]\n}",
"tool_call_id": "call_4d8ebe0a5b8c4d2baab83c46",
"created_at": "2026-06-23T17:27:47.410215"
}
]
},
{
"turn_id": 6,
"started_at": "2026-06-23T17:29:58.459992",
"completed_at": "2026-06-23T17:30:05.543550",
"user_message": "测试测试",
"assistant_final": "收到,测试通过!✅\n\n目前我已准备就绪。如果你想开始正式使用可以尝试\n\n1. **导入数据**`import_meeting_transcript(file_path=\"path/to/your/asr.txt\", knowledge_base_id=\"会议测试\")`\n2. **查询内容**`query_knowledge(query=\"最近有哪些待办事项?\", knowledge_base_id=\"会议测试\")`\n\n请随时下达指令。 😊",
"tool_calls": []
}
]
}

View File

@ -1,54 +0,0 @@
{
"session_id": "default",
"messages": [
{
"role": "user",
"content": "帮我看看最近都开了哪些会?"
},
{
"role": "assistant",
"content": "根据“会议测试”知识库的台账记录,最近的一次会议信息如下:\n\n* **会议日期**2026-06-23 (注:台账中显示的最近日期为 06-23但关联的转录文件日期为 06-11)\n* **会议主题/来源**:文件会议 06-11 15_52\n* **关键内容摘要**\n * **指标**:弱光指标为 0.51(目标 3000状态为达标。\n * **待办**:有一项由“运维”负责的追踪项,时限为“今天”。\n * **风险/已完成**:各抽取到 1 条相关线索。\n\n**台账路径**`data\\会议测试\\team_ledger.md`"
},
{
"role": "user",
"content": "不对吧我明明记得开了不下3次会了"
},
{
"role": "assistant",
"content": "抱歉,刚才的回答确实不全面。通过查看完整的台账记录,我发现您确实开了多次会。\n\n在“会议测试”知识库中最近导入了 **5 次** 会议记录,具体如下:\n\n| 会议日期 | 会议 ID (部分) | 来源文件 |\n| :--- | :--- | :--- |\n| 2026-06-23 | `meeting_b3e96c3ab0` | `transcript_704.md` |\n| 2026-06-23 | `meeting_f7a0963764` | `transcript_710.md` |\n| 2026-06-23 | `meeting_71c6a8ce1b` | `transcript_711.md` |\n| 2026-06-23 | `meeting_9b9a7e718a` | `transcript_712.md` |\n| 2026-06-23 | `meeting_f4029f272b` | `文件会议 06-11 15_52-Transcript.md` |\n\n**台账路径**`data\\会议测试\\team_ledger.md`\n**发现问题**:由于这些会议的导入日期在台账中都标注为 `2026-06-23`,导致查询时未能第一时间区分它们的实际发生顺序。如果您需要了解其中某一次会议的具体内容,请告诉我。"
},
{
"role": "user",
"content": "刚刚我们聊什么来着?"
},
{
"role": "assistant",
"content": "我们刚才在讨论**“会议测试”知识库中最近的会议记录**。\n\n具体过程如下\n1. **您询问**最近开了哪些会。\n2. **我最初只提供了一个**最近会议的摘要,但由于台账记录的原因,信息不够全面。\n3. **您指出**应该开了不止 3 次会。\n4. **我重新检索了完整的台账**,为您列出了该知识库中最近的 **5 次会议记录**(日期均显示为 2026-06-23包含不同的转录文件。\n\n如果您想深入了解其中某次会议的指标、待办或风险可以随时告诉我。"
},
{
"role": "user",
"content": "用你的websearch工具帮我检索一下codex的重置机会重置的是一周的用量还是5小时的"
},
{
"role": "assistant",
"content": "抱歉,我并没有内置的 Web Search网页搜索工具。\n\n作为“会议知识库维护 Agent”我的功能范围主要集中在\n1. **管理和查询“周会经营台账”**(指标、待办、风险、追踪项等)。\n2. **导入会议转录文本**并将其结构化到知识库中。\n\n如果您提到的“codex 的重置机会”是在之前的会议中讨论过的内容,您可以告诉我,我会立即在知识库中为您检索相关的会议记录。如果这是关于外部软件或服务的通用问题,建议您直接使用搜索引擎查询。"
},
{
"role": "user",
"content": "你检查一下你现在拥有的工具"
},
{
"role": "assistant",
"content": "我检查了当前系统分配给我的工具列表,我确实**没有**内置的网页搜索Web Search工具。\n\n我目前拥有的工具及其功能如下\n\n### 1. 核心业务工具(用于维护会议知识库)\n* **`import_meeting_transcript`**: 将会议 ASR 转录文本导入知识库,并提取指标、待办、风险等。\n* **`query_knowledge`**: 查询知识库中的指标、待办、风险、完成事项和追踪项。\n* **`list_knowledge_bases`**: 列出当前所有的知识库(`data/` 下的文件夹)。\n\n### 2. 文件与系统操作工具\n* **`list_directory`**: 查看目录下的文件和子目录。\n* **`read_file`**: 读取文本文件内容。\n* **`write_file`**: 写入或修改文本文件。\n* **`search_files`**: 在工作区内按文件名模式搜索文件。\n* **`execute_shell`**: 执行 shell 命令进行环境检查或只读操作。\n* **`run_python`**: 运行 Python 代码进行数据处理或逻辑计算。\n\n### 3. 辅助工具\n* **`current_time`**: 获取当前系统时间。\n* **`tool_trace_query`**: 回看我之前的工具调用记录。\n\n**结论**:我的能力边界被设定在**管理和查询您提供的会议知识库**。如果您想了解关于 \"codex\" 的信息,除非它之前在会议中被提及并记录到了台账里,否则我无法通过外部互联网获取。"
},
{
"role": "user",
"content": "好吧,还真的没有,是我记错了"
},
{
"role": "assistant",
"content": "没关系的,这很正常!\n\n如果您之后需要查询**会议记录里的具体细节**(比如某个项目的进展、谁负责什么待办、或者某个指标的变动),或者需要**导入新的会议转录文件**,随时叫我就好。\n\n目前我们正在处理“会议测试”这个知识库有什么我可以帮您的吗"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -1,6 +0,0 @@
{
"max_turns": 20,
"next_turn_id": 1,
"active_turn": null,
"turns": []
}

View File

@ -1,46 +0,0 @@
{
"session_id": "meeting_710",
"messages": [
{
"role": "user",
"content": "帮我查查目前的重点工作内容是什么"
},
{
"role": "assistant",
"content": "根据当前的会议台账(`meeting_710/team_ledger.md`),目前的重点工作主要集中在以下三个方面:\n\n1. **产品方案优化(当前状态:阻塞)**\n * **核心目标**解决软硬件方案“拼凑感”强、品牌不统一的问题并提升特定外设如U盾、写字板的兼容性。\n\n2. **销售与售前协作流程优化(进行中)**\n * **关键动作**:细化销售与售前的职责划分以减少冲突;同时优化配置器与报价申请流程,旨在降低售前的工作量。\n\n3. **售后服务体系建设(进行中)**\n * **关键动作**:建立项目分级制度,以确保重点项目的服务质量。\n\n**下一步建议**\n* 由于“产品方案优化”项目目前处于**阻塞**状态,可能需要针对软硬件集成或外设兼容性问题进行专项讨论。\n* 可以关注“销售与售前协作”中关于“配置器优化”的具体进度。"
},
{
"role": "user",
"content": "这是新的一个会议,你帮我整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_711.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md"
},
{
"role": "assistant",
"content": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了河南、山东区域的销售情况,讨论了在价格竞争激烈的背景下,如何通过 **AI 转型**(产品智能化与内部生产力智能化)、**行业场景化细分**(如教育、医疗)以及**优化内部响应流程**(解决测试机申请、研发响应及售后验收问题)来提升竞争力。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)4 个\n - 新增风险点 (Risks)3 个\n - 新增追踪项 (Follow-ups)4 个\n\n**下一步建议:**\n您可以针对“AI 转型方案”、“区域销售策略”或“内部响应流程优化”等具体维度,通过询问“目前有哪些待办事项?”或“有哪些潜在风险?”来深入了解细节。"
},
{
"role": "user",
"content": "汇报一下最新的情况"
},
{
"role": "assistant",
"content": "根据最新的会议复盘2026-06-24目前的业务重点已从单纯的“流程优化”转向了 **“AI 转型”** 与 **“行业场景化深耕”**。\n\n以下是各维度的最新进展汇报\n\n### 1. 核心战略AI 转型与产品升级\n* **AI 云桌面研发 (进行中)**:战略目标是将云桌面从单一工具升级为“智能生产力平台”。重点在于探索 AI 与终端、数据、算力的深度纳管(如智听云/Next X。\n* **产品方案优化 (阻塞)**:由于市场同质化严重,目前正处于寻找差异化突破口的阶段。重点转向强化**教育、医疗**等细分场景的差异化方案,并加强**国产化适配**案例的积累。\n\n### 2. 市场与渠道进展\n* **区域销售策略 (进行中)**\n * **河南区域**:重点在于挖掘友商代理商并培养“金牌渠道”。\n * **山东区域**:侧重提升渠道拓展的精度,建立品牌信任。\n* **应对价格竞争**:针对服务器涨价导致的成本上升,计划通过强化“安全管控(数据不落地)”及提供“微胖”方案来对抗 PC 方案的竞争。\n\n### 3. 内部流程与服务保障\n* **销售与售前协作 (推进中)**:重点解决影响业务效率的痛点,包括**标准化资料更新慢**、**测试机申请周期长**以及**研发 Bug 修复响应慢**的问题。\n* **售后服务体系 (推进中)**:重点解决山东区域(如法院项目)遗留项目验收难的问题,旨在通过提升服务能力来维护品牌价值。\n\n---\n\n**💡 关键观察:**\n目前**“产品方案优化”**仍处于**阻塞**状态建议下一步重点关注“AI 转型”如何转化为可落地的“行业差异化方案”,以破解同质化竞争。\n\n**您可以继续追问:**\n* “目前有哪些具体的待办事项 (Actions)?”\n* “有哪些潜在的风险 (Risks) 需要关注?”\n* “关于 AI 研发有哪些具体的追踪项 (Follow-ups)?”"
},
{
"role": "user",
"content": "帮我将新的会议整理进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md"
},
{
"role": "assistant",
"content": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了 **6 月财务毛利情况**、**大额项目(合北智算)签约进展**、**AI 数字人产品逻辑**、**云桌面终端纳管方案**以及**无人机业务**的推进情况。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)6 个\n - 新增风险点 (Risks)5 个\n - 新增追踪项 (Follow-ups)6 个\n - 完成事项 (Completed)1 个\n\n**关键动态:**\n1. **财务/业绩**:毛利率稳定在 35%,重点关注“合北智算”项目(约 7680 万)的落地。\n2. **AI 产品线**:明确了 AI 数字人“简单、高频”的商业逻辑;云桌面研发正转向“胖终端纳管”及“财务数字助理”试点。\n3. **新业务**:无人机业务在推进中,但面临采集数据与执法库对接的**合法性风险**。\n\n**下一步建议:**\n您可以针对“无人机业务的合规性风险”或“AI 数字人产品的落地计划”进行更深入的查询。"
},
{
"role": "user",
"content": "我最近会议的重点内容有哪些?哪些问题比较严重"
},
{
"role": "assistant",
"content": "通过对近期会议(特别是 2026-06-24 的复盘会议)的梳理,我为您总结了重点内容及当前面临的严重问题:\n\n### 核心重点内容\n1. **业绩与重大项目**\n * **财务表现**6 月毛利率稳定在 **35%**。\n * **大单落地****“合北智算”项目**(约 7680 万)已完成签约,是近期最重要的业绩支撑。\n2. **AI 转型战略落地**\n * **产品方向**:确立了 **AI 数字人**“简单、高频”的商业逻辑;**云桌面**正向“胖终端纳管”(如 U 盘启动)及“财务数字助理”试点方向演进。\n3. **业务拓展**\n * **无人机业务**:正在推进中物联及九龙坡项目。\n * **渠道管理**:重点关注渠道商报价波动及区域人员配置。\n\n### ⚠️ 比较严重的问题与风险\n在最近的讨论中以下几个问题需要高度关注\n\n1. **合规性风险(高优先级)**\n * **无人机业务**:目前在推进项目过程中,**采集的数据如何与执法库对接**存在**合法性风险**。如果不解决合规问题,该业务线的扩张将面临法律障碍。\n2. **管理与预测风险**\n * **销售预测准确性**:会议强调目前的销售预测存在问题,销售负责人不能仅盯着“财务收款”,必须结合实际业务进度进行更精准的预测,否则会影响整体经营判断。\n3. **技术与产品瓶颈**\n * **兼容性问题**:终端黑屏等兼容性问题仍需解决。\n * **AI 数据采集**:在 AI 场景下,如何实现高质量、合规的数据采集仍是技术攻关的难点。\n\n**建议关注方向**\n如果您需要决策支持建议优先听取关于**“无人机业务合规方案”**或**“AI 财务助理试点进展”**的专项汇报。"
}
],
"max_persisted_turns": 20
}

View File

@ -1,131 +0,0 @@
{
"max_turns": 20,
"next_turn_id": 8,
"active_turn": null,
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T14:15:24.363881",
"completed_at": "2026-06-24T14:15:40.451527",
"user_message": "帮我查查目前的重点工作内容是什么",
"assistant_final": "根据当前的会议台账(`meeting_710/team_ledger.md`),目前的重点工作主要集中在以下三个方面:\n\n1. **产品方案优化(当前状态:阻塞)**\n * **核心目标**解决软硬件方案“拼凑感”强、品牌不统一的问题并提升特定外设如U盾、写字板的兼容性。\n\n2. **销售与售前协作流程优化(进行中)**\n * **关键动作**:细化销售与售前的职责划分以减少冲突;同时优化配置器与报价申请流程,旨在降低售前的工作量。\n\n3. **售后服务体系建设(进行中)**\n * **关键动作**:建立项目分级制度,以确保重点项目的服务质量。\n\n**下一步建议**\n* 由于“产品方案优化”项目目前处于**阻塞**状态,可能需要针对软硬件集成或外设兼容性问题进行专项讨论。\n* 可以关注“销售与售前协作”中关于“配置器优化”的具体进度。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user is asking to check the current \"key work content\" (重点工作内容).\nI need to query the knowledge base for this information.\nSince the user hasn't specified a `knowledge_base_id`, I should first check if there are any knowledge bases available or if I can just call `query_knowledge` without one.\nAccording to the system prompt: \"用户未指定知识库时,可以省略 knowledge_base_id如果 data/ 下只有一个知识库,工具会自动使用它。\"\nHowever, it's safer to list knowledge bases first if I want to be sure, or just try to query.\nActually, a better first step is to see what knowledge bases exist so I know which one to query or if I need to ask the user.\nBut if I just call `query_knowledge` with the query \"重点工作内容\", it might work if there's only one.\nLet's try `list_knowledge_bases` first to see what's available.",
"result": "{\n \"knowledge_bases\": [\n \"meeting_710\"\n ],\n \"count\": 1\n}",
"tool_call_id": "chatcmpl-tool-b9f7d02dfc654b2d",
"created_at": "2026-06-24T14:15:30.284205"
},
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "目前的重点工作内容是什么"
},
"thought": "The user wants to know the current key work contents (\"目前的重点工作内容是什么\").\nI have already listed the knowledge bases and found one: `meeting_710`.\nSince there is only one knowledge base, I can proceed to query it without explicitly specifying the ID, or I can specify it for clarity.\nI will use `query_knowledge` to find the \"重点工作内容\" (key work contents) in the `meeting_710` knowledge base.",
"result": "{\n \"answer\": \"根据当前会议台账,找到这些相关内容:\\n- [下一步] 销售与售前协作流程优化|细化销售与售前的工作职责划分,减少冲突\\n- [待办] 售后服务体系建设|项目分级制度建立|责任:售后团队|截止:|状态:进行中\\n- [项目] 产品方案完整性与兼容性优化状态阻塞更新解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。\\n- [下一步] 销售与售前协作流程优化|优化配置器与报价申请流程,降低售前工作量\\n- [决策] 售后服务体系建设|建立项目分级制度,确保重点项目的服务质量\\n- [会议摘要] 2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\",\n \"matches\": [\n {\n \"type\": \"下一步\",\n \"project\": \"销售与售前协作流程优化\",\n \"display_text\": \"销售与售前协作流程优化|细化销售与售前的工作职责划分,减少冲突\",\n \"search_text\": \"销售与售前协作流程优化;销售与售前协作流程优化;细化销售与售前的工作职责划分,减少冲突\"\n },\n {\n \"type\": \"待办\",\n \"project\": \"售后服务体系建设\",\n \"display_text\": \"售后服务体系建设|项目分级制度建立|责任:售后团队|截止:|状态:进行中\",\n \"search_text\": \"售后服务体系建设;项目分级制度建立;售后团队;进行中;明确重点与非重点项目的服务边界;售后服务体系建设\"\n },\n {\n \"type\": \"项目\",\n \"project\": \"产品方案完整性与兼容性优化\",\n \"display_text\": \"产品方案完整性与兼容性优化状态阻塞更新解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。\",\n \"search_text\": \"产品方案完整性与兼容性优化产品方案完整性与兼容性优化阻塞解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。兼容性 方案集成 品牌统一 外设\"\n },\n {\n \"type\": \"下一步\",\n \"project\": \"销售与售前协作流程优化\",\n \"display_text\": \"销售与售前协作流程优化|优化配置器与报价申请流程,降低售前工作量\",\n \"search_text\": \"销售与售前协作流程优化;销售与售前协作流程优化;优化配置器与报价申请流程,降低售前工作量\"\n },\n {\n \"type\": \"决策\",\n \"project\": \"售后服务体系建设\",\n \"display_text\": \"售后服务体系建设|建立项目分级制度,确保重点项目的服务质量\",\n \"search_text\": \"售后服务体系建设;售后服务体系建设;建立项目分级制度,确保重点项目的服务质量\"\n },\n {\n \"type\": \"会议摘要\",\n \"project\": \"\",\n \"display_text\": \"2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\",\n \"search_text\": \"2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\"\n }\n ],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\"\n}",
"tool_call_id": "chatcmpl-tool-9cf985245f4c65d2",
"created_at": "2026-06-24T14:15:33.888244"
}
]
},
{
"turn_id": 2,
"started_at": "2026-06-24T14:16:22.711828",
"completed_at": "2026-06-24T14:19:02.319474",
"user_message": "这是新的一个会议,你帮我整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_711.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md",
"assistant_final": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了河南、山东区域的销售情况,讨论了在价格竞争激烈的背景下,如何通过 **AI 转型**(产品智能化与内部生产力智能化)、**行业场景化细分**(如教育、医疗)以及**优化内部响应流程**(解决测试机申请、研发响应及售后验收问题)来提升竞争力。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)4 个\n - 新增风险点 (Risks)3 个\n - 新增追踪项 (Follow-ups)4 个\n\n**下一步建议:**\n您可以针对“AI 转型方案”、“区域销售策略”或“内部响应流程优化”等具体维度,通过询问“目前有哪些待办事项?”或“有哪些潜在风险?”来深入了解细节。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user wants to import a new meeting transcript into the knowledge base.\nThe transcript file path is provided: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md`.\nThe user mentions \"knowledge base\" but doesn't explicitly name a `knowledge_base_id`.\nHowever, the file path contains `kb_3cc4030350de`. I should check if this matches a known knowledge base or if I can infer it.\nWait, the instructions say: \"If the user doesn't specify a knowledge base, you can omit it; if there's only one in `data/`, the tool will automatically use it.\"\nBut I should first check what knowledge bases exist to be precise, or just try to use the tool with the provided path.\nActually, the user says \"整理到知识库里\" (organize into the knowledge base).\nThe file path suggests it belongs to a specific ID or is just an upload path.\nLet's first list the knowledge bases to see if `kb_3cc4030350de` or something similar exists.\nThen I will call `import_meeting_transcript`.\n\nWait, looking at the file path: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md`.\nThe folder `kb_3cc4030350de` might be the `knowledge_base_id`.\nLet's try to list knowledge bases first to be sure.",
"result": "{\n \"knowledge_bases\": [\n \"meeting_710\"\n ],\n \"count\": 1\n}",
"tool_call_id": "chatcmpl-tool-84b57ced84897e62",
"created_at": "2026-06-24T14:16:33.539268"
},
{
"tool_name": "import_meeting_transcript",
"arguments": {
"file_path": "agent_memory/uploads/kb_3cc4030350de/1782281782706_1_transcript_711.md",
"knowledge_base_id": "meeting_710"
},
"thought": "",
"result": "{\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_6c2f32f573\",\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"state_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\state.json\",\n \"summary\": [\n \"本次会议对河南、山东区域销售情况进行了深度复盘,重点探讨了在价格竞争与同质化压力下,如何通过 AI 转型、行业场景化细分及优化内部响应流程来提升竞争力。\",\n \"区域销售反馈价格竞争激烈,需通过教育、医疗等细分场景的差异化方案及控标点提升胜率。\",\n \"明确 AI 转型战略AI 需深度融入产品(智能应用)与内部生产力(智能化知识库/快速问答)。\",\n \"针对服务器涨价导致的 VDI 成本上升,探讨通过强化安全管控(数据不落地)及“微胖”方案应对 PC 方案竞争。\",\n \"要求产品经理转型为“Farmer”模式深入一线调研对手功能及客户真实需求。\",\n \"强调解决测试机申请周期长、研发响应慢及售后遗留项目验收难等影响品牌的问题。\"\n ],\n \"imported\": true,\n \"counts\": {\n \"projects\": 5,\n \"metrics\": 0,\n \"actions\": 4,\n \"risks\": 3,\n \"completed\": 0,\n \"followups\": 4\n }\n}",
"tool_call_id": "chatcmpl-tool-a46cbbab601b6162",
"created_at": "2026-06-24T14:18:55.758992"
}
]
},
{
"turn_id": 3,
"started_at": "2026-06-24T14:19:18.376400",
"completed_at": "2026-06-24T14:19:38.990714",
"user_message": "汇报一下最新的情况",
"assistant_final": "根据最新的会议复盘2026-06-24目前的业务重点已从单纯的“流程优化”转向了 **“AI 转型”** 与 **“行业场景化深耕”**。\n\n以下是各维度的最新进展汇报\n\n### 1. 核心战略AI 转型与产品升级\n* **AI 云桌面研发 (进行中)**:战略目标是将云桌面从单一工具升级为“智能生产力平台”。重点在于探索 AI 与终端、数据、算力的深度纳管(如智听云/Next X。\n* **产品方案优化 (阻塞)**:由于市场同质化严重,目前正处于寻找差异化突破口的阶段。重点转向强化**教育、医疗**等细分场景的差异化方案,并加强**国产化适配**案例的积累。\n\n### 2. 市场与渠道进展\n* **区域销售策略 (进行中)**\n * **河南区域**:重点在于挖掘友商代理商并培养“金牌渠道”。\n * **山东区域**:侧重提升渠道拓展的精度,建立品牌信任。\n* **应对价格竞争**:针对服务器涨价导致的成本上升,计划通过强化“安全管控(数据不落地)”及提供“微胖”方案来对抗 PC 方案的竞争。\n\n### 3. 内部流程与服务保障\n* **销售与售前协作 (推进中)**:重点解决影响业务效率的痛点,包括**标准化资料更新慢**、**测试机申请周期长**以及**研发 Bug 修复响应慢**的问题。\n* **售后服务体系 (推进中)**:重点解决山东区域(如法院项目)遗留项目验收难的问题,旨在通过提升服务能力来维护品牌价值。\n\n---\n\n**💡 关键观察:**\n目前**“产品方案优化”**仍处于**阻塞**状态建议下一步重点关注“AI 转型”如何转化为可落地的“行业差异化方案”,以破解同质化竞争。\n\n**您可以继续追问:**\n* “目前有哪些具体的待办事项 (Actions)?”\n* “有哪些潜在的风险 (Risks) 需要关注?”\n* “关于 AI 研发有哪些具体的追踪项 (Follow-ups)?”",
"tool_calls": [
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "最近的会议更新和当前项目状态"
},
"thought": "The user wants a report on the \"latest situation\" (\"汇报一下最新的情况\").\nBased on the previous turn, the user just imported a new meeting transcript (`transcript_711.md`) into the `meeting_710` knowledge base.\nThe \"latest situation\" likely refers to the updates brought by this most recent meeting or the overall current status of the projects in the ledger after this update.\n\nTo provide a comprehensive and accurate answer, I should:\n1. Query the knowledge base to get the most recent updates or the overall current status.\n2. The `query_knowledge` tool is designed for this. I'll ask for \"latest meeting updates\" or \"current project status and key updates\".\n\nWait, looking at the previous response, the model *already* summarized the import. However, a \"report\" usually implies a more structured summary of the current state of affairs (projects, tasks, risks, etc.) following the latest update.\n\nI will call `query_knowledge` with a query like \"最近的会议更新和当前项目状态\" (recent meeting updates and current project status) to ensure I'm pulling the actual content from the ledger.",
"result": "{\n \"answer\": \"最近一次会议2026-06-24\\n会议标题汇智云桌面业务复盘及 AI 转型专题会议\\n来源文件D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\agent_memory\\\\uploads\\\\kb_3cc4030350de\\\\1782281782706_1_transcript_711.md\\n\\n会议摘要\\n- 本次会议对河南、山东区域销售情况进行了深度复盘,重点探讨了在价格竞争与同质化压力下,如何通过 AI 转型、行业场景化细分及优化内部响应流程来提升竞争力。\\n- 区域销售反馈价格竞争激烈,需通过教育、医疗等细分场景的差异化方案及控标点提升胜率。\\n- 明确 AI 转型战略AI 需深度融入产品(智能应用)与内部生产力(智能化知识库/快速问答)。\\n- 针对服务器涨价导致的 VDI 成本上升,探讨通过强化安全管控(数据不落地)及“微胖”方案应对 PC 方案竞争。\\n- 要求产品经理转型为“Farmer”模式深入一线调研对手功能及客户真实需求。\\n- 强调解决测试机申请周期长、研发响应慢及售后遗留项目验收难等影响品牌的问题。\\n\\n本次涉及项目\\n- 销售与售前协作流程优化:状态=推进中;更新=解决销售反馈的标准化资料更新慢、测试机申请周期长以及研发 Bug 修复响应慢的问题。\\n- 省代赋能与渠道管理:状态=推进中;更新=河南区域重点挖掘友商代理商并培养金牌渠道;山东区域需提升渠道拓展精度及建立信任。\\n- 售后服务体系建设:状态=推进中;更新=解决山东区域遗留项目(如法院项目)验收难及服务能力不足的问题,提升品牌价值。\\n- 产品方案完整性与兼容性优化:状态=阻塞;更新=应对同质化竞争,需强化教育、医疗等细分场景的差异化方案及国产化适配案例。\\n- AI 云桌面 (智听云/Next X) 研发:状态=推进中;更新=将云桌面从单一工具升级为智能生产力平台,探索 AI 与终端、数据、算力的深度纳管。\",\n \"matches\": [],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_6c2f32f573\"\n}",
"tool_call_id": "chatcmpl-tool-b48b27daa6bcfdd3",
"created_at": "2026-06-24T14:19:25.996580"
}
]
},
{
"turn_id": 5,
"started_at": "2026-06-24T14:38:43.766467",
"completed_at": "2026-06-24T14:41:36.810382",
"user_message": "帮我继续将这个会议添加进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283123758_1_transcript_712.md",
"assistant_final": "Agent run cancelled by user.",
"tool_calls": []
},
{
"turn_id": 6,
"started_at": "2026-06-24T14:51:13.958233",
"completed_at": "2026-06-24T14:54:26.383304",
"user_message": "帮我将新的会议整理进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md",
"assistant_final": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了 **6 月财务毛利情况**、**大额项目(合北智算)签约进展**、**AI 数字人产品逻辑**、**云桌面终端纳管方案**以及**无人机业务**的推进情况。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)6 个\n - 新增风险点 (Risks)5 个\n - 新增追踪项 (Follow-ups)6 个\n - 完成事项 (Completed)1 个\n\n**关键动态:**\n1. **财务/业绩**:毛利率稳定在 35%,重点关注“合北智算”项目(约 7680 万)的落地。\n2. **AI 产品线**:明确了 AI 数字人“简单、高频”的商业逻辑;云桌面研发正转向“胖终端纳管”及“财务数字助理”试点。\n3. **新业务**:无人机业务在推进中,但面临采集数据与执法库对接的**合法性风险**。\n\n**下一步建议:**\n您可以针对“无人机业务的合规性风险”或“AI 数字人产品的落地计划”进行更深入的查询。",
"tool_calls": [
{
"tool_name": "import_meeting_transcript",
"arguments": {
"file_path": "D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md",
"knowledge_base_id": "meeting_710"
},
"thought": "The user wants to import a new meeting transcript into the knowledge base.\nThe file path is `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md`.\nBased on the previous interaction, the current knowledge base is `meeting_710`.\nI should use the `import_meeting_transcript` tool.\n\nPlan:\n1. Call `import_meeting_transcript` with the provided `file_path` and `knowledge_base_id='meeting_710'`.",
"result": "{\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_fd54acb19d\",\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"state_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\state.json\",\n \"summary\": [\n \"本次会议复盘了6月财务毛利、重点项目签约进展、AI数字人产品逻辑、云桌面终端纳管方案及无人机业务的推进情况。\",\n \"财务方面毛利率维持在35%,强调销售负责人需提升预测能力。\",\n \"合北智算项目已完成签约涉及金额约7680万。\",\n \"AI数字人产品确定“简单、高频”的商业逻辑面向企业场景落地。\",\n \"云桌面研发聚焦于胖终端纳管U盘启动及财务数字助理的试点应用。\",\n \"无人机业务推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\"\n ],\n \"imported\": true,\n \"counts\": {\n \"projects\": 5,\n \"metrics\": 4,\n \"actions\": 6,\n \"risks\": 5,\n \"completed\": 1,\n \"followups\": 6\n }\n}",
"tool_call_id": "chatcmpl-tool-9bc2ab14288bc88b",
"created_at": "2026-06-24T14:54:17.433475"
}
]
},
{
"turn_id": 7,
"started_at": "2026-06-24T14:59:38.595464",
"completed_at": "2026-06-24T15:00:16.931725",
"user_message": "我最近会议的重点内容有哪些?哪些问题比较严重",
"assistant_final": "通过对近期会议(特别是 2026-06-24 的复盘会议)的梳理,我为您总结了重点内容及当前面临的严重问题:\n\n### 核心重点内容\n1. **业绩与重大项目**\n * **财务表现**6 月毛利率稳定在 **35%**。\n * **大单落地****“合北智算”项目**(约 7680 万)已完成签约,是近期最重要的业绩支撑。\n2. **AI 转型战略落地**\n * **产品方向**:确立了 **AI 数字人**“简单、高频”的商业逻辑;**云桌面**正向“胖终端纳管”(如 U 盘启动)及“财务数字助理”试点方向演进。\n3. **业务拓展**\n * **无人机业务**:正在推进中物联及九龙坡项目。\n * **渠道管理**:重点关注渠道商报价波动及区域人员配置。\n\n### ⚠️ 比较严重的问题与风险\n在最近的讨论中以下几个问题需要高度关注\n\n1. **合规性风险(高优先级)**\n * **无人机业务**:目前在推进项目过程中,**采集的数据如何与执法库对接**存在**合法性风险**。如果不解决合规问题,该业务线的扩张将面临法律障碍。\n2. **管理与预测风险**\n * **销售预测准确性**:会议强调目前的销售预测存在问题,销售负责人不能仅盯着“财务收款”,必须结合实际业务进度进行更精准的预测,否则会影响整体经营判断。\n3. **技术与产品瓶颈**\n * **兼容性问题**:终端黑屏等兼容性问题仍需解决。\n * **AI 数据采集**:在 AI 场景下,如何实现高质量、合规的数据采集仍是技术攻关的难点。\n\n**建议关注方向**\n如果您需要决策支持建议优先听取关于**“无人机业务合规方案”**或**“AI 财务助理试点进展”**的专项汇报。",
"tool_calls": [
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "总结最近几次会议的重点内容,并特别指出哪些问题比较严重(如阻塞状态、高风险项、或需要紧急处理的待办事项)。"
},
"thought": "The user is asking for the \"key content of recent meetings\" and \"which problems are serious\".\nI need to query the knowledge base to find recent meeting highlights and identify critical issues (Risks, Blocked projects, or high-priority Actions/Follow-ups).\n\nThe knowledge base ID is `meeting_710` (based on previous turns).\n\nPlan:\n1. Call `query_knowledge` with a query like \"最近会议的重点内容和严重问题\" or \"summary of recent meetings and serious risks/blocked projects\".\n2. Analyze the result to categorize \"key content\" and \"serious problems\".\n3. Formulate the response.",
"result": "{\n \"answer\": \"最近一次会议2026-06-24\\n会议标题汇智云桌面业务复盘及 AI 转型专题会议\\n来源文件D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\agent_memory\\\\uploads\\\\kb_3cc4030350de\\\\1782283873952_1_transcript_712.md\\n\\n会议摘要\\n- 本次会议复盘了6月财务毛利、重点项目签约进展、AI数字人产品逻辑、云桌面终端纳管方案及无人机业务的推进情况。\\n- 财务方面毛利率维持在35%,强调销售负责人需提升预测能力。\\n- 合北智算项目已完成签约涉及金额约7680万。\\n- AI数字人产品确定“简单、高频”的商业逻辑面向企业场景落地。\\n- 云桌面研发聚焦于胖终端纳管U盘启动及财务数字助理的试点应用。\\n- 无人机业务推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\\n\\n本次涉及项目\\n- 销售与售前协作流程优化:状态=推进中;更新=复盘了6月毛利、定开收入及销售预测情况强调预测需结合实际而非仅看财务收款。\\n- AI 云桌面 (智听云/Next X) 研发:状态=推进中;更新=推进财务数字助理试点研究通过U盘启动实现胖终端纳管并确立数字人产品方向。\\n- 省代赋能与渠道管理:状态=推进中;更新=讨论了渠道商报价波动及云南区域人员入职情况。\\n- 无人机业务:状态=推进中;更新=推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\\n- 产品方案完整性与兼容性优化:状态=推进中;更新=解决终端黑屏兼容性问题并研究AI场景下的数据采集方案。\",\n \"matches\": [],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_fd54acb19d\"\n}",
"tool_call_id": "chatcmpl-tool-8bafccc132a4653c",
"created_at": "2026-06-24T14:59:44.016488"
}
]
}
]
}

View File

@ -1,54 +0,0 @@
{
"session_id": "meeting_knowledge",
"messages": [
{
"role": "user",
"content": "C:\\Users\\admin\\Downloads\\会议原文 帮我将这个路径下的几个md会议文档整理到知识库里知识库名字叫会议测试"
},
{
"role": "assistant",
"content": "在尝试为您整理会议文档时遇到了问题。\n\n我尝试导入 `C:\\Users\\admin\\Downloads\\会议原文` 目录下的文件到“会议测试”知识库,但由于**大模型服务调用超时Request timed out**,导致导入任务失败。\n\n**已尝试处理的文件:**\n1. `transcript_704.md` - ❌ 失败 (超时)\n2. `transcript_710.md` - ❌ 失败 (超时)\n\n**后续建议:**\n这通常是由于后端大模型服务器响应过慢或网络波动引起的。您可以\n1. **稍后重试**:等服务器负载降低后再试。\n2. **检查网络**:确保您的网络环境可以正常访问大模型服务地址。\n\n一旦服务恢复正常您可以再次指令我进行整理。"
},
{
"role": "user",
"content": "帮我将这个文档整理到知识库\n\n附加文本文件已保存可直接读取或导入\n- huiyi.txt: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_2b0479c17171\\1782266715613_1_huiyi.txt"
},
{
"role": "assistant",
"content": "会议文档已成功整理到知识库 **“会议测试”** 中。\n\n**整理结果摘要:**\n- **台账路径**`D:\\github_project\\my_code\\meeting_knowledge\\data\\会议测试\\team_ledger.md`\n- **更新统计**\n - 识别到 **6** 个相关项目\n - 提取了 **3** 项关键指标\n - 整理了 **6** 项待办事项 (Actions)\n - 识别了 **3** 项风险 (Risks)\n - 记录了 **6** 项追踪项 (Follow-ups)\n\n**会议核心内容:**\n1. **宽带运维**:讨论了安装量受天气影响的波动情况,需平衡上门量与安装量。\n2. **PCDN问题**针对PCDN指标恶化需通过分析学校IP并协同限速解决。\n3. **二级站拆除**进度顺利预计4月15日前完成全量拆除及服务费调整。\n4. **商客业务**:市场部需提升业务价值,并于本周汇报招聘及渠道进度。\n5. **活动筹备**体育文化节方阵人员落实中目前尚缺9人。\n\n您可以随时通过提问如“最近有哪些待办”或“PCDN的问题怎么解决来查询详细信息。"
},
{
"role": "user",
"content": "看看我的会议内容"
},
{
"role": "assistant",
"content": "为您查询到知识库“会议测试”中最近一次会议2026-06-24的内容总结如下\n\n### 📅 最近会议概览\n* **日期**2026-06-24\n* **会议主题**:周例会\n* **核心内容**:本次会议主要围绕运维指标、业务恶化问题、拆除进度、市场业务及活动筹备五个方面展开。\n\n### 🚀 项目进展详情\n| 项目名称 | 当前状态 | 核心进展/关注点 |\n| :--- | :--- | :--- |\n| **宽带安装与运维** | 推进中 | 安装量受天气影响有波动,需平衡上门量与安装量。 |\n| **PCDN与专线业务** | 🔴 **阻塞** | **PCDN指标持续恶化**,问题集中在学校出口带宽占用过大。 |\n| **二级站拆除工程** | 推进中 | 拆除、下电及服务费调整工作正在顺利进行。 |\n| **市场与商客业务** | 推进中 | 商客收入稳步增长,重点在于提升拆迁业务的价值。 |\n| **体育文化节筹备** | 进行中 | 正在筹备第4届文化节重点是落实方阵人员。 |\n| **客户满意度管理** | 关注中 | 针对测评结果进行反思,要求提升执行力。 |\n\n---\n**💡 提示:**\n如果您想了解具体的**待办事项 (Actions)**、**风险 (Risks)** 或某个项目的**详细追踪项**,请直接告诉我,例如:“*PCDN的问题有哪些具体的解决措施*” 或 “*列出所有待办事项*”。"
},
{
"role": "user",
"content": "帮我把这个会议也整理一下\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_2b0479c17171\\1782267550230_1_transcript_704.md"
},
{
"role": "assistant",
"content": "会议文档已成功整理到知识库 **“会议测试”** 中。\n\n**整理结果摘要:**\n- **台账路径**`D:\\github_project\\my_code\\meeting_knowledge\\data\\会议测试\\team_ledger.md`\n- **更新统计**\n - 识别到 **4** 个相关项目\n - 整理了 **3** 项待办事项 (Actions)\n - 识别了 **1** 项风险 (Risks)\n - 记录了 **3** 项追踪项 (Follow-ups)\n\n**会议核心内容:**\n1. **定制开发业务**重点在于6月183万收入目标的冲刺需通过降低边际成本来增强竞争力。\n2. **AI 算力资源管理**:强调 Token 和 H20 资源管理需从“游击队”模式转向规模化、流程化的专业管理。\n3. **无人机平台研发**:正在推进与集云结合的方案,重点关注违停抓拍功能及互联网接入。\n4. **产品质量与风险**:数字助理产品框架已定;同时需高度关注**云桌面产品**因性能卡顿导致的退货风险,需加强质量与服务建设。\n\n您可以随时询问“*云桌面有哪些风险?*” 或 “*关于AI算力的待办事项有哪些*” 来获取更详细的信息。"
},
{
"role": "user",
"content": "我最近会议内容有哪些?"
},
{
"role": "assistant",
"content": "为您查询到知识库“会议测试”中最近一次会议的内容总结:\n\n### 📅 最近会议概览\n* **日期**2026-06-24\n* **会议标题**:周例会\n* **来源文件**`transcript_704.md`\n\n### 📝 会议核心摘要\n本次会议主要聚焦于业务目标的冲刺、AI 资源的专业化管理以及产品质量风险的防范:\n1. **定制开发业务**:正在全力冲刺 6 月份 **183 万**的收入目标,并着手规划下半年的任务。\n2. **AI 算力与模型管理**:强调 AI 算力Token 和 H20的管理需要实现从“游击队”模式向**规模化、流程化**的量化管理转型。\n3. **无人机平台**:正在推进与集云结合的方案,重点开发**违停抓拍**及互联网接入功能。\n4. **产品质量预警****云桌面产品**目前存在因性能卡顿导致客户退货的风险,需加强产品质量与服务团队建设。\n5. **数字助理**:产品框架已确定,将通过 AI 实现自动化咨询与配置助手功能。\n\n---\n**💡 提示:**\n如果您需要查看更具体的任务可以问我\n* “*云桌面产品有哪些具体的待办或风险?*”\n* “*关于 AI 算力管理有哪些追踪项?*”"
},
{
"role": "user",
"content": "我上次会议讲的九零工程具体情况怎么样?"
},
{
"role": "assistant",
"content": "根据台账记录,“九零工程”目前处于**待达标**状态。\n\n具体指标如下\n* **项目名称**:宽带安装与运维\n* **核心指标**:九零工程转化率 = **87.35%**\n* **目标值****90%**\n* **当前进度**:虽然月度来看有提升,但距离 90% 的目标还差一点点。"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -1,777 +0,0 @@
未知说话人01
00:01
也是我们也对宽带的重视,也是有一定的效果哈。
未知说话人01
00:06
然后上门处理因为上周第1个是下雨的原因第2个导致的安装的上门的量的下降所以当周的那个上门量还是比较高五百八十哈。所以说那个累计上门量达到了以前以上哈但是离离我们的目标三千户的话这1块稍微进度还是靠后哈主要还是因为上门这块和那个和安装这安装这块的平衡哈要两边都要兼顾。总体的话线路故障的话这1块是比较平稳哈。
未知说话人01
00:37
你那个说法,刚刚说的本来那个安装没得好多量的噻。不是没得量,下雨。
未知说话人01
00:43
下雨打的上周下雨。上面都应该黑皮的啊。上面就比较好的咱们这上面比较好的。那整个加在一起的话你才。前面前面我们不是。累积了35的进度那个进度不高哦。前面2周不是安装达到了一千吧最近。
未知说话人01
00:58
你给我看后面我们这上周我们安装只有接近400。
未知说话人01
01:02
所以说这个安装量和上门量它2个还是就是每1周还是达到了将近1000的上门量就是安装和维护的上门还是有1000然后每个月的四达到了4000的上门量然后再看一下那个。
未知说话人01
01:17
这个刚刚说的就是那个上门整个指标的情况的话就是从上门之后的指标几个关键指标第1个是弱光弱光现在目前是0.51也是在上周持续在向好即将达到零点五的那个目标。三代终端也是因为全年因为去年的目标是七点五今年要求的年度目标是五点五所以说这个需要1个持续往下压的过程这个也是较上周提升了0.1个百分点就是下降了哈就是有有改善。所以说这个是年度目标的话我也跟司空建议了这个指标它会拉入到运维我也建议他们那个每1个季度设1个阶段性的目标不然的话全市的话五点五都达不到现在目前。FPTR现在是达标了就上周这块那达标哈主动过境这块是靠后零点三这是1个上门之后的效果。
未知说话人01
02:07
然后那个九零工程转化率要达到90%退单率要低于七点五。然后整个月度来看的话我们现在是转化率是87.35离90的目标还差一点点。但是当周来看的话主要是天气影响啊。当周的虽然上了80%哈但是离90的目标还是差的很远。但是月度来看的话都是转化率还是有很大的提升哈接近90退单率是6.53。整个退单的那个改约的情况是影响那个转化率的1个主要因素主要是用户原因二十九单覆盖六单天气两单。
未知说话人01
02:41
整个退单来看的话,主要是用户不安装退单十六单卡,长期无法连续三单。
未知说话人01
02:48
还有入错四单低价值入错建设用户入网不通哈这块。3块蓝单的话就是上周的话那个没达到80%哈56.82整体的当月的转化率是75.29也是比八十还是稍微还差一点的哈退单率是44.29。
未知说话人01
03:07
然后退单率比较高的原因主要还是那个B to C的这1块的那个协调问题。这块1个是审核撤单的退单的流程审核已经给施工是那个建议了。当时那个院里经理也专门跟我打电话哈他也是明白了我们想做的那个这个这个这个审核的1个流程意识他们也在正在开发。然后同时的话也那个这个指标也会也会是我们综合运维的1个考核指标。我们也建议了施工是上次来也建议他们这个退单率应该看能不能提出这种分居长如果这种一线审批如果同意了我觉得还是应该提出的啊。不然的话这种团单来了之后啊都是都是都是他自己有一些个人的意识在里面要退单啊。
未知说话人01
03:53
PCDN那个专线持续在恶化然后主要还是学校就是当当月3月份的话是开学之后啊是4.84这个的话1个就是我们把那个涉及到的IP地址已经报市公司帮我们分析这个出口的学校的IP地址因为目前数据我们上周跟他们沟通之后他们现在这1周才能反馈那个啊是集中在1个学校吗2个学校人文和是海南还是还是就2个学校。
未知说话人01
04:22
我但这个我报全职以后他是不是马上就会过限速啊他是要把IP地址分析出来之后要报给学校限速。学校他们现他们现在也在协同他们。啊。我们。我们也在找全职他们能不能帮我们后来限建立这种机制因为三关因为加关和启关现在都已经都施工都不通报了周报也没有了主要是现在主要问题还是在专线。问问题是专线主要还是在学校我们。学校的话因为我们只是提供他的出口带宽他的IP地址啊那些数据的话要经过那个。而且学校的话互联网带宽我们是没收费的。对免费。这个是更不应该出现问题了。哎这个他们这个学校这边的话如果有问题我觉得第1时间就应该直接就在系统上限。对我们也在想这个是正常的这个行为。我们也也想在那个。学生在沟通一下嘛。一旦发现直接限掉他也没什么好说的。他作为1个学校或者是那个什么他那个提供的是正常的那个带宽使用的这个速度。如果他那个这个超过了这个的话那这这这是有问题啊。嗯。我现在在学校全职尽快的话看有没有
未知说话人01
05:33
啊这是这是整个那个P C N啊3次空间主要1项没达标主要就是那个物业点那个本月那个。
未知说话人01
05:42
那个之前是计划的本月但是反馈天线还现在没有过预计4月初进行1个检测轨道。
未知说话人01
05:50
其他的都是达标的。
未知说话人01
05:53
然后唱厅有1项主要是那个其实问题都解决了但就是要带智能体要智能判断剩余工单要逐项的核查是否达标才能算结束。所以说虽然是处理了但是要等到质检其他指标都是达标的。然后还有1个有12项自行工作啊智能添加的就是也包括南中也的反馈就是我们有些打接电话没有接到哈。然后他们也是当周的现在目前的解决方案是爱立信5G基站参数的调整试点带观察效果哈这个近期反映的就是有漏话这种情况比较多5G这1块。
未知说话人01
06:32
然后那个畅想这块全部达标哈就结束了。然后那个超频站点的攻坚这块是是控制还是比较那个有帮助哈。就是上周我们发现了3个超频基站呃有2个原因是铁塔的开关电源故障导致电流过稳所以说也及时的处理就进行了1个恢复。然后还有1个就是传输光纤过大哈这个维护部人员更换前厅后恢复这个也是说把它解决了。然后旁站的故障的话也持续在也是处理也比较低。
未知说话人01
07:03
然后专线护航目前来讲的话118条专线我们目前的话有存量的73条的话已经完成巡检56条还有17条。然后未验收的工程已经建设的有45条全部进行了巡检。然后四公司要求是3月底全量完成巡检目前17条的话我们在月底能够按时进行1个全部巡检来完成。然后主要是那个涉及到存量这1块我们立即力改都改了主要还是涉及到光光缆路由的这块有抢台行动我们会及时的上报给四公司。然后上周的话我跟机科里面的培训客服出了问题主要还是他们内部的那个就是他们的机房和机柜这块的问题比较比较严重哈这块也是。今天我们会就是这1周的话这2天会把全量的清单我们的问题客服处理的问题全量的发给客服经理让他们知晓并与客户沟通。
未知说话人01
07:57
然后当周故障的话是收到报障是33件然后9件跟网络相关主要问题还是光缆这块的问题啊。
未知说话人01
08:07
然后东环呢也持续保持1个良好的1个水平上周还在全市做了1个那个交流。
未知说话人01
08:14
金沙管家这1块重点那个说一下二级因为上周小总也安排了二级。二级的话整个我们目前哈就最新的数据我们现在剩下20206年总共还剩下一百二十一站这二百一百二十一站其实上周也是抓紧处理已经全部下电了。然后目前累计完成拆除是四十六站还有75站。七十五站预计的话我们争取的话是在2周之内完成全量的拆除然后进行只管专署以及电量服务费4月的1个调整。也就相当于在4月15号之前可以全量完成二级的拆除和下电和服务费的调整。
未知说话人01
08:55
好,工程下层项目都很正常哈,除了那个他们那个,那个单子也也在补当中哈。
未知说话人01
09:06
然后其他的就是也都是正常开展的。
未知说话人01
09:09
其他就是没有啥子大的问题啊。
未知说话人01
09:12
还有就是最后1个就是网络质量综合评估体系哈虽然没有发文但是已经已经给已经发下来了。那个我们内部先收到了这1块我们会嗯后面就是下1周的话我们会针对综合网网络运维评估体系进行进行1个那个进行1个分解和目标的1个制定啊这边就没有起来了。
未知说话人02
09:46
25年剩余2项工作哈第1个花果山那个没得啥子变化的第2个微托无人机哈是说的在金南人组织验收以后是花本周哈拜访唐书记进行推动是项目组的1个。
未知说话人02
10:01
嗯上周完成了5项工作哈包括嗯这种要求建委哈对施工司管的框架下整理的工作清单哈以及投资计划专项评估汇报哈建委部答复是已经汇报并且完成。然后市场那块终端占比问题哈嗯是进行了1个全方位工工的1个重点的1个汇报哈形成了1个5个方面哈制定了具体的措施哈嗯解决问题最终又提升啊答复是已经进行了专题研讨汇报。嗯正中安排的收入地图和行业中心的收入地图哈是各中心已经完成并且经嗯要求进行了提交中核集团。嗯打印机哈那块是跟局科到现场办公室沟通以后哈现在已经是收回中国国啊专门为我们嘞调度表哈预留1.1元个打印机哈。同时啊那块我们也在思考哈如何确保哈,了解了他们日常需求的嗯打印数量哈,嗯打印情况以后哈,嗯我们在周边的哈打印店也进行了一些洽谈。
未知说话人02
11:04
最终他们的价格偏差还是很大有十倍以上。啊最后我们嗯跟建跟那个叫啥整整套是吧整套。啊整套。沟通以后到时候我们去试他有需求的时候我们陪到一起去试确保质量的情况下我们呃提供1个相当于外派点进行1个保障确保那个招投标的打印不影响招投标。嗯。
未知说话人02
11:28
然后,那个气象。
未知说话人03
11:30
那个事情给那个行业中心和那个,哎,运输中心都说,都是个人自己要晓得哦,下回不要再给我,再给我以那个理由,再说去答应你有问题哈,那都是个人的问题。
未知说话人03
11:43
请你们通知到位,好吧。
未知说话人02
11:48
然后有7项工作人在推动过程中。第1个是前期交办的预期数据中心哈整体包包装哈那块儿是说政府要拿出方案补贴方案以后的话再汇报。嗯无人机哈那个需求哈结合到落实成为合川本地的1个方案目前没得啥子变化和之前的反馈。嗯然后基建工程2个宣传工作计划中国那边在推动基建工程2个是是嗯留言后哈目前还没下发下发以后我们在优化制定。啊宣传工作那块儿目前说之后哈争取本周完成1个那个嗯推动啊因为工作人兼职的原因啊。然后那个嗯渠道那块儿呢有1个产能评估哈那块儿是计划嗯开了宣传会哈嗯还在梳理方案最后是结果情况预计在本周进行1个专题汇报。嗯专线发展节奏的问题哈希望整体思考整体将。
未知说话人02
12:40
那个数据进行了展示哈然后嗯体现的是整体情况本周专题汇报。嗯然后分局是那个资产优化的运输类的话是形成了那个清单周报。嗯然后那个每周与分局进行沟通形成1个清单的1个呃计划啊具体的优化方案嗯完成以后哈进行1个完结那是在跟进的效果。呃综合当前几点有1条有些做的工作哈请大家知晓。呃3月20号我们也召开了分公司的话呃工会主席面对面哈进行了一些沟通1个是我们的工会经费进行1个公示。啊因为整个工会经费哈从市工会到市公司哈再到分公司都有所压减啊所以今年我们需要一记严考用更少的钱哈办更好的事儿哈。然后主席给我们做了一些具体指引哈大家有啊积极发言。同时今年我们在本周需要上报市公司的软性工程哈嗯其中我们已经确定的啊可以上报哈目前沟通的哈包括我们更衣室哈包括我们食堂的改造。食堂改造那边的话嗯按照之前的方案是大概是二十九点八万哈然后我们将会进行1个审计。呃结合到大家用餐的时间哈我们提出的建议是5月哈结合
未知说话人02
14:54
更替和修补。呃,同时呃,市公司呃呃,和分公司哈,我们做了解以后哈,也是收集大家意见以后,包括啊,我们准备呃引用那个自饮机的方式哈来解决我们用水的问题哈,呃,送水啊那些呃机器也坏了,啊,自饮机那块儿的话也报了市公司哈,对批准,啊,等到去批复以后啊,我们就可以引入,大概在下半年的时候哈,啊,我们就可以用自饮机哈进行饮水,同时也节约成本。
未知说话人02
15:21
幸福一家一的活动哈全年同时组织了六场啊大型活动啊包括3月的运动会啊4月的足球5月的羽毛球6月的篮球10月的气排球和10月的乒乓球。啊分公司对于除了参加四公司活动以外哈整体还安排的有一些重大节假日的一些活动包括近期我们啊在思考筹备的6月二中节啊的1个活动以及我们那个啊班组活动哈班组活动那边的话也是整整出了大家的意见哈可能今年我们也在思考是不是搞1个我们分公司内部的班组之间的啊嗯气排球比赛啊通过那种形式哈来解决那个啊班组各自开展活动哈那种另行开展的那个方式哈然后根据的一些啊就是在1个思考过程中。嗯同时重点汇报一下周五哈计划的是第4届体育嗯文化节啊我们河川将以河川佳节哈穿越巅峰一起新嗯一起新城然后动要赛场哈1个主题口号结合到我们泡竹汤哈啊出名的1个形式通过我们方正和演员的演出哈将我们啊深情的河川河川的那种啊快乐啊热情的那种精神面貌哈展示给全市的啊兄弟公司和领导
未知说话人02
17:08
啊,原来黄翔啊,他们都是因为啊有伤在身啊,无法参加。啊,他们觉着不行啊。啊,他不得行。
未知说话人02
17:17
这是哪儿受伤了啊?呃,他肌肉拉伤。
未知说话人02
17:23
呃,没法成交吗?哎,对,他不敢跑,对吧?
未知说话人02
17:27
方向啥情况啊?呃也是。
未知说话人03
17:31
2个2个主力选手都不得行哦那那个今年是那个有点儿具体哦平时主持体格。
未知说话人02
17:38
呃,他是那么跟我说的。
未知说话人03
17:41
我29然后他们七八。
未知说话人03
17:44
看得起啊,我说没看得起黑老婆呀。
未知说话人02
17:48
啊,他不敢不敢爆发爆发性的发力,那种。如果是,如果是确实受伤了,我觉得吧,就不能勉强,因为我。
未知说话人02
17:58
然后完了以后的话就是需要各部门儿积极配合特此请示领导。1个就是我们方阵哈目前除了我们参赛的队员儿和我们呃和本人哈作为方阵队员儿以外哈可能我们容纳到20个人来进行那个活动开展。嗯再加上领导和1个举牌手和1个那个形象嗯大师以外我们一共需要25人。好我们确定的呃报名人数以外的话我们还差9个人还差9个人的话看呃结合到那个一线生产工作嘞繁忙哈嗯那个过度嗯看是不是各部门儿再出2个人哈。啊构成我们8人的方阵哈另外AI运输中心出1个人哈我们总共目前差9个人。啊确定人员的话需要今天确定确定以后我们需要给他们购买衣服。啊购买衣服以后周二到周四进行排练啊就是下班以后然后星期五都去参加活动。好那块儿的话看领导觉得合适那种分配。
未知说话人02
18:55
1个男生整起来。
未知说话人03
19:00
电脑都没地方每个部门2个人该出出来是这个想法。
未知说话人02
19:05
嗯,周二到周四要拍的呀。
未知说话人02
19:07
买了几件参加哈。
未知说话人03
19:10
不是确实跟到2个人都看不出来跟我说。
未知说话人02
19:15
性别性呢?呃,不行。
未知说话人03
19:19
大家说清楚有衣服的。
未知说话人02
19:23
同时呢参加以后介会我还上决赛哈和往年一样积极参加的还是有一定的绩效的1个加分儿只要参加方阵的是每人一分儿获获得名次的啊更多一些。
未知说话人02
19:36
第2个都是嗯请知晓今天的主题教育哈公司也是啊已经发布了第1期的教育简报啊总体来说都是第1期就是公布公司哈啊在召开了主题教育的专题的1个那个啊工作。
未知说话人02
19:51
第2个基层党组织哈就是上次我们也开了哈截止到3月15号所有的基层党组织也开展了学习后面我们再按照其那个动作来执行。好因为呃主题教育请大家知晓动作。第2个都是嗯市公司也要求哈。
未知说话人02
20:06
招待费哈要进行1个信息公开那块儿公开的对象和范围啊都是本单位嗯管理层的那个嗯班子成员哈那个范围哈。所以前期我们也是通过邮件的方式将25年啊啊招待费的形式进行1个发布和公示也完成了向纪检组织的啊报备。好请领导知晓哈这项工作已经完成。等我问一下它那个公开是每年公开1次吗还是每1次都要公开呀每年1次现在每年。
未知说话人02
20:38
然后那个25年整体招待费使用的情况哈嗯那个嗯也嗯做好做个汇报哈25年哈我们整体招待费的使用是超我们年初预算是14%哈其中综合部哈是逐年增长哈24年、25年哈逐年增长嗯增长去非常明显哈超预算也非常多哈我们综合部去年超了107%好所以26年的话也给综合部调整了预算哈其他部门因为涉及到成本的管控哈目前没有调整预算嗯26年使用来看的话哈目前也是综合部哈嗯以及我们的市场部市场部因为年初的渠道大会哈啊整体它的那个时间节奏可能不太一样哈包括我们超改分局和南京的分局哈相对使用较快哈是超过了时间进度好其他一些部门的招待费预算力度啊是是在进度范围之内好可能更多的嗯要进行嗯特别提醒嘛我觉得提醒啊整体可以嗯其实是有钱哈在那个1个范围之内可以合理的规划按时间进度哎安排更多的同志哈参与的同志都可以报哈包括嗯因为之前我们也盘点的哈嗯那个包括安运输中心
未知说话人03
22:00
情况的1个汇报和通报。看到的给我说一下就是关于政企部啊因为我们的整个对外招待集福来政企部啊。
未知说话人03
22:10
这起步那边的话呀,因为有些重要单位又集中在个别的客户经理那边儿。
未知说话人03
22:16
哦,所以说这种啊,要经历比较统筹。
未知说话人03
22:20
那个统筹安排你不能说1个客服经理。
未知说话人03
22:23
太多,是吧?然后比如说,有像那种的,比如哎,哎,服务中心的那边的街坊啊,那些也可以,就大家分到分到来,哦,那边你跟和谐商量,把那个,就说实实在在去,是真实发生的费用啊,尽快的解决和。
未知说话人03
22:43
不要拖,然后据我了解,去年子还有些费用都没还没完。
未知说话人03
22:47
今天是油来了,是吧?
未知说话人03
22:49
哦,那个事情呢,我认为它是个,不是个啥子大事情,不需要喊我们那个公公司领导市民来操心的那个事。
未知说话人02
23:01
然后河川区发了1个通知哈叫做河川区担当作为先进集体和先进个人。因为之前我们也报过河川强区啊企业的先进集体、先进个人好目前还在区领导那里已经嗯提交区领导1个因为目前区领导还没得指示。好同时收到弄了1个通知嗯那个文件的话相当于之前他的那个我们区的文件内容是嗯区级的部门儿啊单位以及区国资委啊管辖的企业。好因为今年呢领导班子也特别提到计划我们要逐渐在河川区啊加强叫做呢嗯增强我们区的影响力。所以我觉得那个可能也是1个点说我们也单独进行了一些追踪和嗯询问哈那个下发单位哈不是区委办啊府办嗯区委组织部以及区人力社保局啊他们组建成了专班。好联系人是委办1个通知和人力社保局哈看到他们他们是经办的那个负责发文的同志。好相当于嗯经过咨询1个嗯争取到相当于我们嗯如果有意意愿参加的话也可以参加。看了下前一届的那个评选名单呢没得那种我们那种企业哈都是政府的一些组织和机构。好那个
未知说话人02
24:23
首先因为它那个报名简单啊就是报材料嘛我们来签头写就说看给不给合川移动1个资格啊。有那个资格的话呀相当于可以差异化地与竞争对手拉开差距。目前竞争对手应该是对那个还是没有引起重视也没有动。啊青云集团有三十多名。
未知说话人02
24:42
有了名额嘛去报嘛这是他的程序哈。嗯要求的是3月31号之前哈也是1个网上申请需要进行1个报名。啊所以用那个事情啊下面都。
未知说话人02
24:53
的回报。
未知说话人02
24:55
啊,以上是综合部的汇报。
未知说话人03
25:00
他达到啥子水平才能够担当?
未知说话人02
25:03
啊,它跟我们平一样的,都是嗯。
未知说话人02
25:08
那20个类似于20个方身都是文字型的。
未知说话人02
25:15
不是。
未知说话人02
25:17
就说工作突成绩突出,认真负责,类似于那种那种的,他都是套话。所以说那种东西,它主要还是在于。
未知说话人02
25:27
给不给你的革命呢?
未知说话人02
25:30
负责的是尾巴还是社保局他们是联合工作组通知里头有尾巴和社保局2个联系单位应该是他们2个都在。
未知说话人02
25:39
啊,相当于突出的评选都是政治忠诚可靠啊,担当过硬啊,都是套话这个。套话。哎,所以那个工作成绩突出啊,什么,嗯,整体形象好啊,然后那个纪律严明,就像强区那个一样,实际上他也是那种历史套话。啊,所以说那块儿的话,我个人觉得还是从今历史判断来看,还是要有有推荐,然后你再去报,不然的话确实有点浪费。
未知说话人02
26:06
啊,那块儿是委办室叫陈红同志,啊,然后人力资源局是叫崔磊同志,也跟他们联系了,他们说他们都是发会议的,具体经办呢。
未知说话人02
26:16
收入跟那个差距。
未知说话人02
26:21
没得没问到。你要去了解噻。哎都是可能看那块儿是正气气了解。就是有有有。行政区可能都是就是讲它那边主要那个方面是吧然后它那个是哪1个。
未知说话人03
26:33
尾巴是是那个。
未知说话人02
26:37
对的,我都说建议争取跟能联系,因为我们问了他们的,他说我是发会议的,我不清楚,让我鼓捣他去问呢,对不?也不太高兴。嗯。
未知说话人03
26:47
那个我们没得好大好大的那个的话,我们去花那个时间也没得好大意义的。学校的练习有。
未知说话人02
26:55
我印象中好像,那是发通报的时候。
未知说话人03
26:58
好像有1个员工还有个我说你们去了解一下如果是那种的话我们也可以跟这边的领导先沟通一下。
未知说话人03
27:06
但是我现在不晓得是哪个领导在负责那个事儿。
未知说话人03
27:09
因为你至少是委办主任那那个层面嘛,或者是或者是分管那个领导嘛,去单独去了解,好吧。他们如果,如果有那个的话,我们去去争取多,确实,别个都是拼的区头的,啊,我们也不用花那个时间。
未知说话人03
27:25
是吧?了解是吧?
未知说话人03
27:29
今儿不是聊天。
未知说话人01
27:32
那个你那个整个三期要的第2期的工作做1个嗯项目汇报。现在是拆迁方面我们整个刚刚去在了解的我们一些那个拆迁目前已经完成了1145。好整个还有呢是。嗯拆迁方式我们那个通过当通过那个注册那个单位上那么逐步好那个做510000的价值提升都是他原来的通货没有达到50的我们提升到50。好那么那个会议还有里面有好多个成员都可以说那个收入。好那么还有1个都是就是我们办理那个比如说新业不拆迁都是2次通货15.1元个月。我们做了2个2次通货都是1个1个那个整体层面的1个拆迁。好那么党建网拆迁都是我们的那个那个新增的通货。好包括有文化套餐好做了那个他会一笔一笔进行进行算。好就是一季度的整个整个拆迁是主要的还是在通货场景我们原来的通货没达到50的做一些新的价值提升说完成率啊成绩比较好。那个是整个拆迁1个情况。好那么在在整个历史融合在在这些包括上面呢那个会议的那个政府那个啊还是继续
未知说话人01
30:23
单位上集中做的啊是和前方在那个还有京东他们说也也有一些社区做的啊那种家庭的清洗和我们那个相结合嘛两种方式做点看看哪种效果好。因为像在社区做的啊我们就像比如说像区区委组织部家属院建设要家属院小区啊那种和单位客户比较集中的小区啊我们去开展一些一些单位上的客户一些服务工作。啊那么在也是做的啊本周的1个做的情况本周做的啊一共开展了五场啊那个落实相对较好那个业务量的话也还是没有达到我们想要的成果好特别是在在落实上面的签约好其他的方案的话集资签约的板块的话那边我在本周啊还要进行1个加强。
未知说话人01
31:04
商客市场1个情况整个整个商客的话到了我们到了2月份收入是88.5好比上个一期增加了10000。好完成较好的是超改那个城北和南京改增幅都上了20000好一家农家乐出现了负200块钱的1个情况。啊整体个商客的1个规模情况和那个都还是比较好。啊那个在价值拓展方面的话我们是完成了115。好同的是第6和下一期我再把其他公司做1个展示。因为因为那个我们我仅分全县的问题目前只能看到个的。好排名是找上市公司个的。好我下一期把那个整个全市的1个排名好做1个展现。
未知说话人01
31:40
那个是整个价值拓展的1个情况。
未知说话人01
31:44
他那个重点的工作啊都在商客经理方面的话整个目前我们还有2个人。嗯那是以周为单位周为单位曾会流的话那个星期他的他的那个基础业务只有两笔所以说相对于较差一点。我们是以周为单位在在统计我下期把累计的嗯按累计来嘛。
未知说话人01
32:05
它那个是整个材料会吧。
未知说话人04
32:13
只需要。
未知说话人04
32:15
我份工作还有没得?
未知说话人05
32:20
在种。
未知说话人05
32:22
第1次建委那边哈那个几十铺哈。
未知说话人05
32:25
之前有个那个。
未知说话人05
32:27
想的也。
未知说话人06
32:31
那个。
未知说话人06
32:33
咸鱼粉。
未知说话人06
32:36
每个单位长一些,大约啥子?
未知说话人06
32:39
我先模仿一下。
未知说话人05
32:42
第2关然后再次接放大是那样的。
未知说话人05
32:47
就可以上清华反正第2天你已经理了一些哈我觉得能够把那个。
未知说话人05
32:51
公司人也很少的。
未知说话人05
32:55
进一步结合地方的改造。
未知说话人05
32:59
那个。
未知说话人05
33:00
厨房的一些。
未知说话人05
33:06
第2就是建委今年那个综合评估体系已经出来了。
未知说话人05
33:11
不知道怎么了。
未知说话人05
33:12
把前面。
未知说话人05
33:15
疗效。
未知说话人05
33:19
啊第2个关于正畸康复包括运输已经7号就说了第1个点。
未知说话人05
33:24
上海人多。
未知说话人05
33:26
经理要当天处理,及时回复。
未知说话人05
33:30
像刚才,中国不同宝的。
未知说话人05
33:32
那个建伟部主儿,肯定是收到,跟到给我回复,把事情是完结了。
未知说话人05
33:37
啊,我们这几块儿的经理基本上也不在那个板块的站。
未知说话人05
33:41
哦,也不好意思啊,我在会议上说了,私下也说了很多次了。
未知说话人05
33:45
要养成那种日日日清、日清日结、日结日高的好习惯。
未知说话人05
33:51
就算没完成,阶段性的来沟通一下儿,有什么问题困难,我们一起来想办法推进。
未知说话人05
33:57
你不能1个事情有些工作一说几个月都还是还没做。
未知说话人05
34:04
回复工作是首先回复安排工作的领导。
未知说话人05
34:09
而不是去肉包的时候会不丰富。
未知说话人05
34:12
我要养成好习惯,动作要推得早。
未知说话人05
34:15
不好意思啊,我都不知道得罪儿童怎么。
未知说话人05
34:18
我都当是我的嘛。
未知说话人05
34:21
并且恢复工作一定是要量化的。
未知说话人05
34:24
要有结果。
未知说话人05
34:26
要有措施,要有成效。
未知说话人05
34:33
第2关于上课这个指标我也说了很多次了我们没看到具体的保障措施。只有最后1周了早上上班的时候也跟廖经理做了沟通需要拿出具体可行的方案。
未知说话人05
34:47
那个1月5号那天我就说了是结合专线。
未知说话人05
34:51
助账账客单位的业务发展。
未知说话人05
34:56
各项指标都会好,方法我也教了,也没看到动作出来。
未知说话人05
35:01
你没看到回复。
未知说话人05
35:04
请今天要给我1个具体的回复哪个能保证那项指标达成
未知说话人05
35:13
重新想啊,其他东西。
未知说话人03
35:19
关于那个市场部的工作说。
未知说话人03
35:22
3个方面第1个方面就是。
未知说话人03
35:26
整个1个资料的1个存储安排就是。
未知说话人03
35:29
1个是本季度本本季度的1个1个1个收官之日。
未知说话人03
35:34
刚才谈电脑,按照目标来推。
未知说话人03
35:38
治疗的第2个像小方面都是基本上那个周也是。
未知说话人03
35:43
最后1周了。
未知说话人03
35:45
本本季度都是新区营销不厚,我觉得完了过后还是有一些按部就班的一些,大家有点皮,包括那个整个业务推进,市场方面也比较也比较皮软。
未知说话人03
35:57
呃,请商户提前谋划。
未知说话人03
35:59
二级部的1个业务活动那把生日。
未知说话人03
36:04
不能说二季度是传统的淡季。
未知说话人03
36:08
不管是我的签约也好商人也好H也好还是那个AI军团也好那个业务活动的那个升职还是要要造起来哈。
未知说话人03
36:19
电脑上不提。
未知说话人03
36:21
第2个方面就是那个呃上周那个根据我看那个能力地图收入地图已经已经发过来了。
未知说话人03
36:28
呃,请那个商务部去到中心。
未知说话人03
36:32
馆主。
未知说话人03
36:34
本周内,把涉及到销售能力的几个事情的进度。
未知说话人03
36:40
给我做1次报告1个是招聘的情况。
未知说话人03
36:44
第2个是农村渠道的。那个
未知说话人03
36:47
进度第3个是上周营销那个事情。
未知说话人03
36:52
他没在1个。
未知说话人03
36:53
具体的方案西消队伍建设和转型本周内给我1个这3个事情。
未知说话人03
37:00
做个1次汇报。
未知说话人03
37:03
3个字蚂蚁多。
未知说话人03
37:06
满意度这1次满意度的测评结果上周五林哥说了个话。
未知说话人03
37:13
我当时问,还是觉得这个事情,不管从我还是。
未知说话人03
37:18
商务部和政企部,这个工作确实是。
未知说话人03
37:22
有烛火。
未知说话人03
37:27
这次是那个,都是,应该是最后,就是按照老的方式来测评嘛。
未知说话人03
37:33
下1块就是用啊用后机评来测评啊。
未知说话人03
37:36
3月份还有。
未知说话人02
37:38
有前期的,没就会咯。但前期不会在错。
未知说话人03
37:44
所以说这个事情,我觉得,但是真的要反思一下儿。
未知说话人03
37:49
满一度的工作,你是做了吗?还是做到了?
未知说话人03
37:55
正起步之前都是我们满一做了1个1个拉分项1个开卷考试。廖经理是不是思想上松懈了因为除了夏总参加的客户会我看到廖经理之外其他次的客户会廖经理都不在。
未知说话人03
38:11
都是1个都是1个货开卷儿炸。
未知说话人03
38:15
我觉得这个事情你应该是可控的,你才不,你才不关注这个事情。
未知说话人03
38:23
补充句啊,近期不要主观对这个。
未知说话人03
38:27
除廖经理以外,你们哪个主管对郑启满队负责?
未知说话人03
38:31
我支持好看点吧。
未知说话人03
38:34
我上次上次不是跟廖经理说的,如果说货款有,这个我们就抓起来啊,让他亲自抓。
未知说话人07
38:50
麻烦等会儿,那个。
未知说话人07
38:52
我觉得现在就是叫啥子啊,亡羊补牢为时未晚,这个事情现在还有。
未知说话人07
39:00
还有时间,但是我觉得市场不和自己。
未知说话人07
39:05
在在抓这个工作的时候,老师出了问题才重视,才才反转,才抓微笑曲线。我觉得这种工作方式和工作态度,你说我们要反思一下。
未知说话人07
39:17
另外就是关于那个,就是客户的工作,中中国的时候我也跟跟那个。
未知说话人07
39:24
财务部的领导做的够好,嗯,有几点。
未知说话人07
39:28
也是同步市场部也是给项目做1个做。
未知说话人07
39:32
就是那个第1个都是八零的两率。
未知说话人07
39:36
就是到3月底特别是3月底就这个话关键门儿说市公司会有统一。
未知说话人07
39:44
是不是会通用?所以说,就是它的满意的样板儿。
未知说话人07
39:48
下来就是你跟那个小付一起,在我办公室我们讨论一下,满意客户满意的样板一定要按照四公司的规定来。
未知说话人07
39:58
第2个是对于不满的客户。
未知说话人07
40:03
它有2个时间节点1个是五点半1个是五点五点半过后。
未知说话人07
40:08
还有五点半有几个操作的1个有1个有个技巧。同时不满的客户要报警客户。因为现在不是这个指标不是KPK考核集团的但是啊总是要求的要考核分公司的。所以说这个事情在操作上面是有空间的。
未知说话人07
40:32
然后那个80200000000我也看。
未知说话人07
40:36
那还是不要我们目前是属于一类的到属于第2的左右。
未知说话人07
40:42
也是也是不是低于全市的平均,所以说我觉得这个事情也是没有快速的把市公司的有一些技巧性的动作启动。
未知说话人07
40:58
好,在考题上面都是八零两率,是有打印后面的。
未知说话人07
41:04
这个是可以,是个是可以来平衡的。
未知说话人07
41:08
好,这块儿的话,就是请那个。
未知说话人07
41:11
商户,把每天的那个,就是每天,就是到到月底之前,每天微信上面出个日报。
未知说话人07
41:21
发送给发给我和小红嘛。
未知说话人07
41:25
然后,然后高关于考核,我也大概问了一下。
未知说话人07
41:29
嗯,法律两界还是有那个,就是有可能有比较。
未知说话人07
41:33
有可能又不高也没说定。今年的KPAN考还基本上定了是工信部的有责。
未知说话人07
41:40
要考KPI啊还有就是你的网率就是跟满意度相关的客户不走就是按照那个观点驾驶提升就你的网率不高于去年你应该是个你应该是会拿个KPI来考核。
未知说话人08
41:57
应该是1个就是。
未知说话人08
42:00
目前都是定了的就是2个考核1个是工信部有责1个是。
未知说话人08
42:04
都去。
未知说话人07
42:06
8.2月是8.2月是有有打印。
未知说话人07
42:11
大概是这个样子,所以说把那个考核的情况你进一步再去摸细,还有那些都是可以可控的操作的一些动作。
未知说话人07
42:23
跟子公司的话。
未知说话人07
42:27
对吧商务部都说的3个方面商务部的话我们最关注是把运动会、本周运动会。
未知说话人07
42:33
对朱子豪。
未知说话人07
42:35
嗯,我是星期跟下午到了,我要星期星期星期三、星期四出差,星期五到时候。
未知说话人07
42:43
然后有啥事情,就是给我报告的事情。如果是来点计划,就明天明天,明儿下午,反正说几个事情。
未知说话人07
42:53
给我汇报,如果来不及的话,都都用视频。好,星期星期五上午。
未知说话人09
43:06
红火亮的樱桃。
未知说话人10
43:11
KPI。
未知说话人10
43:13
刚才会上我们也过了1遍按照我们定好的目标确保完成。
未知说话人10
43:20
好,马云的话。
未知说话人10
43:22
具体的一些情况,请你们这个会后。
未知说话人10
43:27
1个详细规划。
未知说话人10
43:29
啊,我想强调的是。
未知说话人10
43:32
自信的。
未知说话人10
43:34
我觉得,不管是部门儿还是。
未知说话人10
43:38
不管是部门经理还是主管还是具体抓工作的人和每1个一线的同志们大家都要有那个执行力。
未知说话人10
43:47
事情要办法,事不做好。
未知说话人10
43:52
要知道怎么做,但是就是不去做。
未知说话人10
43:56
那我觉得那是我们公司至少。
未知说话人10
43:59
我们班子几个人是,我相信都不认可的那种工作作风。
未知说话人10
44:10
还有4公司的那个年度考核。
未知说话人10
44:13
觉得几个部门主要是去了解情况啊,那个提前也给你们给说过几次。
未知说话人10
44:19
啊,如果有。
未知说话人10
44:21
对我们影响比较大的一些病毒,或者是。
未知说话人10
44:25
你要提前讲,不要等到起那个,别个都定好了再来跟我说,夏总,那个考核是今年子我们要照。
未知说话人10
44:34
对,我说很。
未知说话人10
44:37
我们把工作做在前面你们细细了解。我其实有点担心2个哈1个是。
未知说话人10
44:43
1个叫啥子啊那个阿布咖啡。
未知说话人10
44:46
那个全市那个集团公司有1个啥子阿普提升啥子考的我不知道他怎么考我不清楚。
未知说话人10
44:53
很默契。
未知说话人10
44:55
提升或者改善,或者啥,阿婆的啥子啥子。
未知说话人10
44:58
我觉得那个对,我我们公司,我不知道说不有。
未知说话人10
45:02
我们要去,不管哪个样子,我觉得考核,我们其实我们不要求上市公司好像要要要对其他公司有啥子。
未知说话人10
45:10
我要去想那些事情但是至少是比较公平的。我们通过努力我们可以去进行1个得乎点儿改善的。
未知说话人10
45:18
最主要单位。
未知说话人10
45:20
有提醒过,我我那边的话也会。
未知说话人10
45:24
2个分管领导里面的话也跟他们2个主要部门嘛政企部跟。
未知说话人10
45:29
他说:“我们加强力度嘛,然后如果确实有有必要的话,哈,我们可以去。”
未知说话人10
45:35
也是。
未知说话人10
45:36
好,那些沟通。
未知说话人10
45:39
是1个年龄的1个非常重要的一点啊不要开始我们处在起跑线上那个很麻烦不像我印象我们当时那个近视还在有1年说我们那个啥子。
未知说话人10
45:50
心也不靠海,是吧?
未知说话人10
45:53
开始都是主要在细胞线上面,啊,最开始为啥子不够通行?有那种卡位口结和那些。
未知说话人10
45:59
是吧?我也相信,市公司他也应该会考虑分公司的整体情况,然后统筹来,至少大家都能够调高摸得度,是吧?
未知说话人10
46:14
今天的周会就到这儿结束。

View File

@ -65,6 +65,10 @@ class ConversationSession:
self.max_iterations = max_iterations
self.max_history_turns = max_history_turns
self.max_persisted_turns = max_persisted_turns
self.max_tool_result_chars_for_model = 12000
self.max_tool_string_field_chars = 1500
self.max_tool_list_items = 8
self.max_tool_dict_items = 20
self.session_dir = self.workspace / "agent_memory" / "sessions" / self.session_id
self.history_path = self.session_dir / "recent_history.json"
self.tool_trace_store = ToolTraceStore(self.session_dir / "tool_trace.json", max_turns=max_history_turns)
@ -201,7 +205,14 @@ class ConversationSession:
result=result,
tool_call_id=call.id,
)
messages.append({"role": "tool", "tool_call_id": call.id, "name": call.name, "content": result})
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": self._tool_result_for_model(call.name, result),
}
)
yield ChatEvent(type="tool_result", tool_name=call.name, tool_args=call.arguments, tool_result=result, iteration=iteration, max_iterations=self.max_iterations)
if repeated_loop_detected:
@ -288,6 +299,64 @@ class ConversationSession:
base_prompt=self.system_prompt,
)
def _tool_result_for_model(self, tool_name: str, result: str) -> str:
text = str(result or "")
if len(text) <= self.max_tool_result_chars_for_model:
return text
try:
payload = json.loads(text)
except json.JSONDecodeError:
compact_text = self._compact_tool_value(text, depth=0)
summary = {
"_tool_name": tool_name,
"_truncated_for_model": True,
"_original_chars": len(text),
"_note": "Full tool result was stored in tool_trace.json. Use tool_trace_query if more detail is needed.",
"result_summary": compact_text,
}
return json.dumps(summary, ensure_ascii=False, indent=2)
summary = {
"_tool_name": tool_name,
"_truncated_for_model": True,
"_original_chars": len(text),
"_note": "Full tool result was stored in tool_trace.json. Use tool_trace_query if more detail is needed.",
"result_summary": self._compact_tool_value(payload, depth=0),
}
return json.dumps(summary, ensure_ascii=False, indent=2)
def _compact_tool_value(self, value: Any, *, depth: int) -> Any:
if depth >= 4:
if isinstance(value, (dict, list)):
return f"[{type(value).__name__} truncated]"
return self._compact_scalar(value)
if isinstance(value, dict):
items = list(value.items())
compact: dict[str, Any] = {}
for index, (key, item) in enumerate(items):
if index >= self.max_tool_dict_items:
compact["_remaining_fields"] = len(items) - self.max_tool_dict_items
break
compact[str(key)] = self._compact_tool_value(item, depth=depth + 1)
return compact
if isinstance(value, list):
compact_list = [
self._compact_tool_value(item, depth=depth + 1)
for item in value[: self.max_tool_list_items]
]
if len(value) > self.max_tool_list_items:
compact_list.append(f"... {len(value) - self.max_tool_list_items} more items")
return compact_list
return self._compact_scalar(value)
def _compact_scalar(self, value: Any) -> Any:
if isinstance(value, str):
compact = " ".join(value.split())
if len(compact) > self.max_tool_string_field_chars:
return compact[: self.max_tool_string_field_chars] + "...[truncated]"
return compact
return value
def _assistant_message(turn: AssistantTurn) -> Dict[str, Any]:
message: Dict[str, Any] = {"role": "assistant", "content": turn.content or ""}

View File

@ -1,101 +0,0 @@
# nanobot WebUI Source
This directory contains the React/TypeScript source for the nanobot WebUI. If
you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI,
read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need
Node.js, Bun, Vite, or anything in this directory unless you are changing the
frontend.
For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md).
## Pick a Path
| Goal | Start with | Opens at |
|---|---|---|
| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` |
| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://<your-ip>:8765` |
| Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` |
| Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes |
The source app is built with Vite + React 18 + TypeScript + Tailwind 3 +
shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and
reads session metadata from the embedded REST surface on the same port.
## Layout
```text
webui/ source tree (this directory)
../webui/dist/ build output served by the gateway
```
## Develop the WebUI (Vite HMR)
### 1. Install nanobot from source
From the repository root:
```bash
python -m pip install -e .
```
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
### 2. Enable the WebSocket channel
In `~/.nanobot/config.json`, merge:
```json
{ "channels": { "websocket": { "enabled": true } } }
```
### 3. Start the gateway
In one terminal:
```bash
nanobot gateway
```
### 4. Start the WebUI dev server
In another terminal:
```bash
cd webui
bun install # npm install also works
bun run dev
```
Then open `http://127.0.0.1:5173`.
By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
If your gateway listens on a non-default port, point the dev server at it:
```bash
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
```
## Build for packaged runtime
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
If you want to preview the production bundle locally without rebuilding the wheel:
```bash
cd webui
bun run build # writes to ../webui/dist in this project
```
The gateway picks up the new bundle on the next restart.
## Test
```bash
cd webui
bun run test
```
## Acknowledgements
- [`agent-chat-ui`](https://github.com/langchain-ai/agent-chat-ui) for UI and interaction inspiration across the chat surface.

View File

@ -26,8 +26,8 @@ class TerminalRenderer:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Meeting knowledge agent CLI.")
parser.add_argument("--data-dir", default="data", help="台账和结构化数据保存目录。")
parser = argparse.ArgumentParser(description="Meeting memory agent CLI.")
parser.add_argument("--data-dir", default="data", help="会议记忆与原文归档保存目录。")
parser.add_argument("--once", default="", help="只执行一条消息,适合测试。")
parser.add_argument("--offline", action="store_true", help="只用于本地演示:强制使用离线规则 provider不调用大模型。")
parser.add_argument("--list-models", action="store_true", help="列出当前 OpenAI-compatible 服务暴露的模型名。")
@ -66,9 +66,9 @@ def main() -> None:
renderer.render_event(event)
return
print("Meeting Knowledge Interactive Agent")
print(r"示例:导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt 到知识库 合川分公司")
print("示例:专线护航还有哪些待办?知识库 合川分公司")
print("Meeting Memory Interactive Agent")
print(r"示例:导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt")
print("示例:最近一次会议提到了哪些待办?")
print("输入 exit / quit 退出。")
while True:
try:

View File

@ -1,5 +1,3 @@
"""Meeting knowledge demo package."""
from meeting_memory.config import config
from .service import MeetingKnowledgeService
__all__ = ["MeetingKnowledgeService"]
__all__ = ["config"]

View File

@ -0,0 +1,46 @@
import os
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
PACKAGE_ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(PACKAGE_ROOT)
class LLMConfig(BaseModel):
api_key: str = Field(default=os.getenv("LLM_API_KEY", ""))
base_url: str = Field(default=os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"))
model: str = Field(default=os.getenv("LLM_MODEL", "deepseek-chat"))
max_tokens: int = Field(default=64000)
temperature: float = Field(default=0.95)
class EmbeddingConfig(BaseModel):
api_key: str = Field(default=os.getenv("EMBEDDING_API_KEY", ""))
api_base: str = Field(default=os.getenv("EMBEDDING_BASE_URL", "https://api.openai.com/v1"))
model: str = Field(default=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"))
class StorageConfig(BaseModel):
data_dir: str = Field(default=os.path.join(PROJECT_ROOT, "data"))
raw_dir: str = Field(default=os.path.join(PROJECT_ROOT, "data", "raw"))
class Neo4jConfig(BaseModel):
enabled: bool = Field(default=os.getenv("NEO4J_ENABLED", "false").lower() == "true")
uri: str = Field(default=os.getenv("NEO4J_URI", "bolt://localhost:7687"))
user: str = Field(default=os.getenv("NEO4J_USER", "neo4j"))
password: str = Field(default=os.getenv("NEO4J_PASSWORD", ""))
database: str = Field(default=os.getenv("NEO4J_DATABASE", "neo4j"))
class ProjectConfig(BaseModel):
llm: LLMConfig = Field(default_factory=LLMConfig)
embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
neo4j: Neo4jConfig = Field(default_factory=Neo4jConfig)
state_path: str = Field(default=os.path.join(PROJECT_ROOT, "data", "meeting_state.json"))
config = ProjectConfig()

View File

@ -0,0 +1,143 @@
import hashlib
import logging
import math
import re
from typing import Any, Dict, List, Set, Tuple
logger = logging.getLogger(__name__)
_MINHASH_SEEDS = [
5483, 7361, 8919, 1103, 4597, 6827, 2341, 9973,
3851, 6217, 7589, 1447, 5021, 8363, 2711, 9539,
1973, 6689, 4157, 8831, 3209, 7547, 1627, 5981,
4363, 8707, 2857, 7193, 1543, 6299, 4729, 9067,
]
_BAND_SIZE = 4
_NUM_BANDS = len(_MINHASH_SEEDS) // _BAND_SIZE
_FUZZY_JACCARD_THRESHOLD = 0.9
def normalize_name(name: str) -> str:
return re.sub(r'\s+', ' ', name.strip().lower())
def shannon_entropy(text: str) -> float:
if not text:
return 0.0
lowered = text.lower()
freq: Dict[str, int] = {}
for c in lowered:
freq[c] = freq.get(c, 0) + 1
entropy = 0.0
for count in freq.values():
p = count / len(lowered)
entropy -= p * math.log2(p)
return entropy
def _shingle(s: str, k: int = 3) -> Set[str]:
return {s[i:i + k] for i in range(len(s) - k + 1)}
def _minhash_signature(text: str) -> List[int]:
shingles = _shingle(text)
sig = []
for seed in _MINHASH_SEEDS:
min_hash = min(
int(hashlib.md5(f'{seed}{s}'.encode()).hexdigest()[:8], 16)
for s in shingles
)
sig.append(min_hash)
return sig
def _lsh_bands(signature: List[int]) -> Set[Tuple[int, int]]:
bands = set()
for i in range(_NUM_BANDS):
start = i * _BAND_SIZE
band_hash = hash(tuple(signature[start:start + _BAND_SIZE]))
bands.add((i, band_hash))
return bands
def _jaccard_similarity(sig_a: List[int], sig_b: List[int]) -> float:
if not sig_a or not sig_b:
return 0.0
intersection = sum(1 for a, b in zip(sig_a, sig_b) if a == b)
return intersection / len(sig_a)
def resolve_with_similarity(
extracted: List[Dict[str, Any]],
existing: List[Dict[str, Any]],
) -> Tuple[List[int], List[int]]:
resolved_indices: List[int] = []
unresolved_indices: List[int] = []
existing_norm: Dict[str, Dict] = {}
for c in existing:
norm = normalize_name(c.get('name', ''))
if norm:
existing_norm[norm] = c
for c in existing:
c_name = c.get('name', '')
if c_name:
entropy = shannon_entropy(c_name)
tokens = normalize_name(c_name).split()
if len(c_name) >= 6 and len(tokens) >= 2 and entropy >= 1.5:
c['_minhash'] = _minhash_signature(c_name)
else:
c['_minhash'] = None
for idx, entity in enumerate(extracted):
name = entity.get('name', '')
if not name:
unresolved_indices.append(idx)
continue
norm = normalize_name(name)
if norm in existing_norm:
match = existing_norm[norm]
entity['_resolved_to'] = match
entity['name'] = match.get('name', entity['name'])
resolved_indices.append(idx)
continue
tokens = norm.split()
entropy = shannon_entropy(name)
can_fuzzy = len(name) >= 6 and len(tokens) >= 2 and entropy >= 1.5
if can_fuzzy:
entity_sig = _minhash_signature(name)
entity_bands = _lsh_bands(entity_sig)
best_match = None
best_score = 0.0
for c in existing:
c_sig = c.get('_minhash')
if c_sig is None:
continue
c_bands = _lsh_bands(c_sig)
if not entity_bands & c_bands:
continue
score = _jaccard_similarity(entity_sig, c_sig)
if score >= _FUZZY_JACCARD_THRESHOLD and score > best_score:
best_score = score
best_match = c
if best_match is not None:
entity['_resolved_to'] = best_match
entity['name'] = best_match.get('name', entity['name'])
entity['_match_score'] = best_score
resolved_indices.append(idx)
continue
unresolved_indices.append(idx)
for c in existing:
c.pop('_minhash', None)
return resolved_indices, unresolved_indices

View File

@ -1,260 +1,633 @@
from __future__ import annotations
import hashlib
import json
import logging
import re
from pathlib import Path
import sys
from enum import Enum
from typing import Any, List, Optional
from .models import ExtractedMeeting
from openai import OpenAI
from pydantic import BaseModel, Field
from meeting_memory.config import config
from meeting_memory.prompts import extract_entities as prompt_extract_entities
from meeting_memory.prompts import extract_facts as prompt_extract_facts
from meeting_memory.prompts import resolve_entities as prompt_dedupe_nodes
from meeting_memory.prompts import resolve_facts as prompt_dedupe_edges
from meeting_memory.prompts import summarize_entity as prompt_summarize
logger = logging.getLogger(__name__)
client = OpenAI(
api_key=config.llm.api_key or None,
base_url=config.llm.base_url if config.llm.base_url else None,
)
TIME_RE = re.compile(r"^\d{1,2}:\d{2}$")
SPEAKER_RE = re.compile(r"^未知说话人\d+")
class EntityType(str, Enum):
DEPARTMENT = 'Department'
PROJECT = 'Project'
METRIC = 'Metric'
PERSON = 'Person'
SYSTEM = 'System'
DOCUMENT = 'Document'
PARTICIPANT = 'participant'
UNKNOWN = 'Unknown'
KEYWORDS = {
"action": ("", "需要", "", "争取", "预计", "本周", "下周", "今天", "月底", "会后", "汇报", "完成", "制定", "上报"),
"risk": ("问题", "风险", "未达标", "靠后", "恶化", "影响", "没有", "没达", "退单", "故障", "不满"),
"done": ("已完成", "完成了", "已经完成", "达标", "解决了", "已提交", "已汇报", "已报备"),
# Normalization map: legacy LLM output → canonical type
_ENTITY_TYPE_ALIASES = {
'组织': 'Department',
'organization': 'Department',
'部门': 'Department',
'指标': 'Metric',
'kpi': 'Metric',
'项目': 'Project',
}
METRIC_PATTERNS = (
re.compile(r"(?P<name>弱光).*?(?P<value>\d+(?:\.\d+)?)"),
re.compile(r"(?P<name>转化率).*?(?P<value>\d+(?:\.\d+)?)"),
re.compile(r"(?P<name>退单率).*?(?P<value>\d+(?:\.\d+)?)"),
re.compile(r"(?P<name>蓝单).*?(?P<value>\d+(?:\.\d+)?)"),
re.compile(r"(?P<name>满意度).*?(?P<value>\d+(?:\.\d+)?)"),
re.compile(r"(?P<name>KPI).*?(?P<value>\d+(?:\.\d+)?)"),
)
TARGET_PATTERNS = (
re.compile(r"目标(?P<target>\d+(?:\.\d+)?%?)"),
re.compile(r"达到(?P<target>\d+(?:\.\d+)?%?)"),
re.compile(r"低于(?P<target>\d+(?:\.\d+)?%?)"),
re.compile(r"小于(?P<target>\d+(?:\.\d+)?%?)"),
)
def _canonical_entity_type(raw: str) -> str:
normalized = raw.strip()
if normalized in _ENTITY_TYPE_ALIASES:
return _ENTITY_TYPE_ALIASES[normalized]
for member in EntityType:
if member.value.lower() == normalized.lower():
return member.value
return EntityType.UNKNOWN.value
def extract_meeting(text: str, *, source_path: str | Path, meeting_date: str) -> ExtractedMeeting:
source = str(Path(source_path).resolve())
meeting_id = _meeting_id(source, text)
utterances = _parse_utterances(text)
meeting = ExtractedMeeting(meeting_id=meeting_id, meeting_date=meeting_date, source_path=source)
for item in utterances:
content = _clean_sentence(item["content"])
if len(content) < 8:
continue
evidence = {"time": item["time"], "speaker": item["speaker"], "text": content}
meeting.evidence.append(evidence)
if _contains(content, KEYWORDS["done"]):
meeting.completed_items.append(_item(content, evidence, status="已完成/已处理"))
if _contains(content, KEYWORDS["risk"]):
meeting.risks.append(_risk(content, evidence))
if _contains(content, KEYWORDS["action"]):
meeting.action_items.append(_action(content, evidence))
metric = _metric(content, evidence)
if metric:
meeting.metrics.append(metric)
meeting.summary = _make_summary(meeting)
meeting.followups = _make_followups(meeting)
meeting.metrics = _dedupe_dicts(meeting.metrics, ("name", "value", "evidence"))
meeting.action_items = _dedupe_dicts(meeting.action_items, ("title", "evidence"))
meeting.risks = _dedupe_dicts(meeting.risks, ("title", "evidence"))
meeting.completed_items = _dedupe_dicts(meeting.completed_items, ("title", "evidence"))
return meeting
def _neo4j_labels(entity_type: str) -> list[str]:
canonical = _canonical_entity_type(entity_type)
labels = ['Entity']
if canonical != EntityType.UNKNOWN.value:
labels.append(canonical)
return labels
def _parse_utterances(text: str) -> list[dict[str, str]]:
lines = [line.strip() for line in text.splitlines() if line.strip()]
utterances: list[dict[str, str]] = []
speaker = ""
time = ""
pending: list[str] = []
def flush() -> None:
nonlocal pending
if pending:
utterances.append({"speaker": speaker, "time": time, "content": " ".join(pending)})
pending = []
for line in lines:
if SPEAKER_RE.match(line):
flush()
speaker = line
time = ""
continue
if TIME_RE.match(line):
flush()
time = line
continue
pending.append(line)
flush()
return utterances
class Entity(BaseModel):
name: str
entity_type: str = EntityType.UNKNOWN.value
description: str = ''
def _clean_sentence(text: str) -> str:
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"[啊哈嗯呃]{2,}", "", text)
return text
class Relation(BaseModel):
source_entity_name: str
target_entity_name: str
relation_type: str
fact: str = ''
valid_at: str = ''
invalid_at: str = ''
evidence: str = ''
qualifiers: List[str] = Field(default_factory=list)
confidence: float = 0.0
def _contains(text: str, words: tuple[str, ...]) -> bool:
return any(word in text for word in words)
class ActionItem(BaseModel):
task: str
assignee: str = ''
deadline: str = ''
status: str = '待办'
priority: str = ''
def _item(content: str, evidence: dict[str, str], *, status: str) -> dict[str, str]:
return {
"title": _short_title(content),
"status": status,
"owner": _guess_owner(content),
"deadline": _guess_deadline(content),
"evidence": _evidence_ref(evidence),
class Decision(BaseModel):
content: str
proposer: str = ''
status: str = '已决'
class MeetingMetric(BaseModel):
metric_name: str
value: str
target: str = ''
owner: str = ''
trend: str = ''
unit: str = ''
class DepartmentInfo(BaseModel):
name: str
description: str = ''
projects: List[str] = Field(default_factory=list)
class MeetingExtraction(BaseModel):
title: str
date: str = ''
participants: List[str] = Field(default_factory=list)
agenda: List[str] = Field(default_factory=list)
entities: List[Entity] = Field(default_factory=list)
relations: List[Relation] = Field(default_factory=list)
action_items: List[ActionItem] = Field(default_factory=list)
decisions: List[Decision] = Field(default_factory=list)
metrics: List[MeetingMetric] = Field(default_factory=list)
departments: List[DepartmentInfo] = Field(default_factory=list)
summary: str = ''
def _call_llm(
messages: list[dict],
response_model: type | None = None,
stream: bool = False,
max_tokens: int | None = None,
) -> Any:
kwargs = {
'model': config.llm.model,
'messages': messages,
'max_tokens': max_tokens or config.llm.max_tokens,
'temperature': config.llm.temperature,
}
if response_model is not None:
kwargs['response_format'] = {'type': 'json_object'}
if stream:
kwargs['stream'] = True
if not stream:
response = client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
if content is None:
raise ValueError('LLM returned empty response')
return content
def _action(content: str, evidence: dict[str, str]) -> dict[str, str]:
return {
"title": _short_title(content),
"owner": _guess_owner(content),
"deadline": _guess_deadline(content),
"next_step": _short_title(content, limit=80),
"status": "待跟进",
"evidence": _evidence_ref(evidence),
}
def _risk(content: str, evidence: dict[str, str]) -> dict[str, str]:
return {
"title": _short_title(content),
"impact": _guess_impact(content),
"current_handling": _short_title(content, limit=90),
"evidence": _evidence_ref(evidence),
}
def _metric(content: str, evidence: dict[str, str]) -> dict[str, str] | None:
for pattern in METRIC_PATTERNS:
match = pattern.search(content)
if not match:
kwargs['stream'] = True
response = client.chat.completions.create(**kwargs)
chunks: List[str] = []
print('\n[LLM] 开始流式输出:')
for event in response:
if not event.choices:
continue
name = match.group("name")
value = match.group("value")
target = ""
for target_pattern in TARGET_PATTERNS:
target_match = target_pattern.search(content)
if target_match:
target = target_match.group("target")
break
status = "待判断"
if "达标" in content and "未达标" not in content:
status = "达标"
elif "没达" in content or "未达标" in content or "靠后" in content:
status = "未达标/靠后"
elif "向好" in content or "提升" in content or "改善" in content:
status = "改善中"
return {
"name": name,
"value": value,
"target": target,
"status": status,
"trend": _guess_trend(content),
"evidence": _evidence_ref(evidence),
}
return None
delta = event.choices[0].delta.content
if not delta:
continue
chunks.append(delta)
sys.stdout.write(delta)
sys.stdout.flush()
print('\n[LLM] 输出结束')
return ''.join(chunks)
def _make_summary(meeting: ExtractedMeeting) -> list[str]:
parts = []
if meeting.metrics:
parts.append(f"抽取到 {len(meeting.metrics)} 条指标状态。")
if meeting.action_items:
parts.append(f"抽取到 {len(meeting.action_items)} 条待跟进事项。")
if meeting.risks:
parts.append(f"抽取到 {len(meeting.risks)} 条问题/风险线索。")
if meeting.completed_items:
parts.append(f"抽取到 {len(meeting.completed_items)} 条已完成或已处理事项。")
return parts or ["本次会议已导入,未抽取到明显结构化事项。"]
def _make_followups(meeting: ExtractedMeeting) -> list[dict[str, str]]:
followups = []
for item in meeting.action_items[:20]:
if item["deadline"] or any(word in item["title"] for word in ("汇报", "完成", "上报", "回复")):
followups.append(
{
"title": item["title"],
"owner": item["owner"],
"deadline": item["deadline"],
"evidence": item["evidence"],
}
)
return followups
def _short_title(text: str, *, limit: int = 44) -> str:
text = re.sub(r"[。!?].*", "", text).strip(" ,。")
return text if len(text) <= limit else text[:limit].rstrip() + "..."
def _guess_owner(text: str) -> str:
for owner in ("运维", "商务部", "市场部", "综合部", "政企部", "施工", "客服", "分局", "四公司", "市公司"):
if owner in text:
return owner
return "待确认"
def _guess_deadline(text: str) -> str:
patterns = (
r"今天",
r"本周内?",
r"下周",
r"月底",
r"\d+月\d+号?之前",
r"\d+月底",
r"\d+周之内",
r"会后",
)
for pattern in patterns:
match = re.search(pattern, text)
def _try_parse_json(content: str) -> dict | list:
try:
return json.loads(content)
except json.JSONDecodeError:
logger.warning('JSON parsing failed; trying to repair extracted block')
match = re.search(r'\{.*\}|\[.*\]', content, re.DOTALL)
if match:
return match.group(0)
return ""
try:
return json.loads(match.group())
except json.JSONDecodeError as exc:
logger.error('Repaired JSON still failed to parse: %s', exc)
raise
def _guess_impact(text: str) -> str:
for word in ("指标", "考核", "转化率", "退单率", "满意度", "专线", "客户", "故障"):
if word in text:
return f"影响{word}相关工作"
return "影响待确认"
def _normalize_string(name: str) -> str:
return re.sub(r'[\s]+', ' ', name.strip().lower())
def _guess_trend(text: str) -> str:
if "恶化" in text or "下降" in text:
return "变差/下降"
if "向好" in text or "提升" in text or "改善" in text:
return "向好/改善"
if "平稳" in text:
return "平稳"
return ""
def _format_episodes_for_context(episodes: list[dict] | None) -> str:
if not episodes:
return ''
return '\n'.join(
f'[Episode {i}] {ep.get("content", "")}'
for i, ep in enumerate(episodes)
)
def _evidence_ref(evidence: dict[str, str]) -> str:
return f"{evidence.get('time', '')} {evidence.get('speaker', '')}".strip()
# ===== Step 1: 实体节点抽取 =====
def extract_entities_from_text(
text: str,
previous_episodes: list[dict] | None = None,
entity_types: list[dict] | None = None,
stream: bool = False,
) -> list[dict]:
context = {
'episode_content': text,
'previous_episodes': previous_episodes or [],
'entity_types': entity_types or [],
}
messages = prompt_extract_entities(context)
content = _call_llm(messages, stream=stream)
try:
data = _try_parse_json(content)
except Exception as exc:
logger.error('Failed to parse entity extraction result: %s', exc)
return []
if isinstance(data, dict):
data = data.get('entities', data.get('extracted_entities', []))
if not isinstance(data, list):
return []
result = []
for item in data:
if isinstance(item, dict) and item.get('name', '').strip():
result.append({
'name': item['name'].strip(),
'entity_type': item.get('entity_type', 'Entity'),
'description': item.get('description', ''),
'evidence': item.get('evidence', ''),
})
return result
def _dedupe_dicts(items: list[dict[str, str]], keys: tuple[str, ...]) -> list[dict[str, str]]:
seen: set[tuple[str, ...]] = set()
output: list[dict[str, str]] = []
for item in items:
marker = tuple(str(item.get(key, "")) for key in keys)
if marker in seen:
# ===== Step 2: 实体去重 =====
def resolve_entities_against_graph(
extracted: list[dict],
existing: list[dict],
episode_content: str = '',
) -> list[dict]:
if not existing:
return extracted
context = {
'extracted_entities': extracted,
'existing_entities': existing,
'episode_content': episode_content,
}
messages = prompt_dedupe_nodes(context)
content = _call_llm(messages)
try:
data = _try_parse_json(content)
except Exception as exc:
logger.warning('LLM dedup failed, keeping all extracted: %s', exc)
return extracted
if isinstance(data, dict):
data = data.get('entity_resolutions', data.get('resolutions', []))
extracted_by_id = {i: e for i, e in enumerate(extracted)}
existing_by_id = {c.get('candidate_id'): c for c in existing}
for resolution in (data if isinstance(data, list) else []):
if not isinstance(resolution, dict):
continue
seen.add(marker)
output.append(item)
return output
rid = resolution.get('id')
dup_id = resolution.get('duplicate_candidate_id', -1)
if rid is None or rid not in extracted_by_id:
continue
if dup_id >= 0 and dup_id in existing_by_id:
extracted_by_id[rid]['_resolved_to'] = existing_by_id[dup_id]
extracted_by_id[rid]['name'] = resolution.get('name', extracted_by_id[rid]['name'])
return [e for e in extracted_by_id.values() if '_resolved_to' not in e]
def _meeting_id(source: str, text: str) -> str:
digest = hashlib.sha1((source + "\n" + text[:4000]).encode("utf-8", errors="ignore")).hexdigest()[:10]
return f"meeting_{digest}"
# ===== Step 3: 事实关系抽取 =====
def extract_facts_from_text(
text: str,
entities: list[dict],
reference_time: str = '',
previous_episodes: list[dict] | None = None,
stream: bool = False,
) -> list[dict]:
if len(entities) < 2:
return []
context = {
'episode_content': text,
'entities': entities,
'reference_time': reference_time,
'previous_episodes': previous_episodes or [],
}
messages = prompt_extract_facts(context)
content = _call_llm(messages, stream=stream)
try:
data = _try_parse_json(content)
except Exception as exc:
logger.error('Failed to parse fact extraction result: %s', exc)
return []
if isinstance(data, dict):
data = data.get('edges', data.get('facts', data.get('relations', [])))
if not isinstance(data, list):
return []
entity_names = {_normalize_string(e.get('name', '')) for e in entities}
result = []
for item in data:
if not isinstance(item, dict):
continue
src = _normalize_string(item.get('source_entity_name', ''))
tgt = _normalize_string(item.get('target_entity_name', ''))
if src not in entity_names or tgt not in entity_names:
continue
if src == tgt:
continue
result.append({
'source_entity_name': item['source_entity_name'],
'target_entity_name': item['target_entity_name'],
'relation_type': item.get('relation_type', '关联'),
'fact': item.get('fact', ''),
'valid_at': item.get('valid_at', ''),
'invalid_at': item.get('invalid_at', ''),
'evidence': item.get('evidence', ''),
'qualifiers': item.get('qualifiers', []),
'confidence': item.get('confidence', 0.0),
})
return result
# ===== Step 4: 事实去重/矛盾检测 =====
def resolve_facts_against_graph(
new_fact: dict,
existing_facts: list[dict],
invalidation_candidates: list[dict],
) -> dict:
if not existing_facts:
return {'is_duplicate': False, 'is_contradicted': False, 'resolved': new_fact}
context = {
'new_fact': new_fact.get('fact', ''),
'existing_facts': existing_facts,
'invalidation_candidates': invalidation_candidates,
}
messages = prompt_dedupe_edges(context)
content = _call_llm(messages)
try:
data = _try_parse_json(content)
except Exception as exc:
logger.warning('Fact dedup failed, treating as new: %s', exc)
return {'is_duplicate': False, 'is_contradicted': False, 'resolved': new_fact}
if not isinstance(data, dict):
return {'is_duplicate': False, 'is_contradicted': False, 'resolved': new_fact}
return {
'is_duplicate': len(data.get('duplicate_facts', [])) > 0,
'is_contradicted': len(data.get('contradicted_facts', [])) > 0,
'resolved': new_fact,
'duplicate_facts': data.get('duplicate_facts', []),
'contradicted_facts': data.get('contradicted_facts', []),
}
# ===== Step 5: 实体摘要 =====
def extract_entity_summary(
entity_name: str,
episodes: list[str],
existing_summary: str = '',
previous_episodes: list[dict] | None = None,
) -> str:
context = {
'entity_name': entity_name,
'episodes': episodes,
'existing_summary': existing_summary,
'previous_episodes': previous_episodes or [],
}
messages = prompt_summarize(context)
content = _call_llm(messages, max_tokens=1024)
try:
data = _try_parse_json(content)
except Exception:
logger.warning('Failed to parse summary, using empty')
return ''
if isinstance(data, dict):
return data.get('summary', '')
return ''
# ===== 统一入口(兼容原有接口) =====
EXTRACTION_SYSTEM_PROMPT = """
你是一个专业的会议知识抽取助手你的任务是从中文会议记录中抽取结构化事实尤其要抽出更细粒度更有语义深度的关系
输出要求
1. 只输出一个 JSON 对象不要输出解释文字
2. 关系抽取不要停留在"部门汇报了工作"这种浅层描述要尽可能向下细化到
- 责任归属
- 目标值 / 当前值 / 趋势
- 约束条件
- 因果 / 影响
- 时间要求
- 依赖关系
- 部署 / 决策 / 要求 / 风险 / 支撑关系
3. 每条关系尽量同时给出
- subject / predicate / object
- fact: 一句自然语言事实表述
- qualifiers: 限定条件范围状态数值约束等
- evidence: 原文中的关键短句或压缩证据
- confidence: 0 1 之间
- valid_at / invalid_at: 如果文中明确提到时间可填写否则留空
4. 如果原文存在多个事实不要只抽象概括要拆成多条关系
5. 避免空泛关系词优先使用更具体的谓词例如
- 负责 / 汇报 / 目标值 / 当前值 / 低于 / 高于 / 要求 / 督导 / 推进 / 影响 / 支撑 / 依赖 / 计划 / 完成 / 截止于
"""
def extract_meeting_info(text: str, stream: bool = False) -> MeetingExtraction:
user_prompt = f"""
请从下面会议记录中提取结构化信息并重点做"深层关系抽取""层次结构识别"
输出 JSON 字段
- title
- date
- participants
- agenda
- entities: name, entity_type, description
- entity_type 请使用: Department部门Project项目Metric指标Person人物System系统Document文档
- relations:
- source_entity_name: 源实体名称
- target_entity_name: 目标实体名称
- relation_type: 关系类型 HAS_PROJECTHAS_METRIC负责汇报目标值推进依赖
- fact: 一句自然语言事实描述
- valid_at可选
- invalid_at可选
- evidence: 原文证据
- qualifiers: 限定条件列表
- confidence: 0~1
- action_items: task, assignee, deadline, status, priority
- decisions: content, proposer, status
- metrics: metric_name, value, target, owner, trend, unit
- departments: [{{"name": "部门名称", "description": "", "projects": ["项目名1", "项目名2"]}}]
- summary
层次关系规则
1. Department 管辖 Project relation_type HAS_PROJECT
2. Project 拥有 Metric relation_type HAS_METRIC
3. 其他事实关系负责汇报目标值等直接用 relation_type 表达
关系抽取规则
1. 不要只抽"汇报了工作"这种会议动作要尽量继续下钻出具体事实
2. 如果一句话里同时包含"主体 + 指标 + 当前值 + 目标值 + 负责人 + 趋势"应拆成多条关系或在 qualifiers 中保留这些细节
3. 对于"要求、部署、负责、依赖、影响、约束、目标、风险"类信息优先保留
4. fact 必须是一句完整自然可检索的事实描述
5. qualifiers 用于补充数值范围状态条件截止时间优先级等信息
6. evidence 用原文中的关键词短句不要太长
7. confidence 取值 0 1
会议记录如下
{text}
"""
content = _call_llm([
{'role': 'system', 'content': EXTRACTION_SYSTEM_PROMPT},
{'role': 'user', 'content': user_prompt},
], stream=stream)
data = _try_parse_json(content)
data = _normalize_meeting_data(data)
return MeetingExtraction(**data)
def _normalize_meeting_data(data: dict) -> dict:
if not isinstance(data, dict):
return {}
return {
'title': _as_str(data.get('title')),
'date': _as_str(data.get('date')),
'participants': _as_str_list(data.get('participants')),
'agenda': _as_str_list(data.get('agenda')),
'entities': _normalize_entities(data.get('entities')),
'relations': _normalize_relations(data.get('relations')),
'action_items': _normalize_action_items(data.get('action_items')),
'decisions': _normalize_decisions(data.get('decisions')),
'metrics': _normalize_metrics(data.get('metrics')),
'departments': _normalize_departments(data.get('departments')),
'summary': _as_str(data.get('summary')),
}
def _as_str(value) -> str:
if value is None:
return ''
if isinstance(value, str):
return value
return str(value)
def _as_float(value) -> float:
if value is None or value == '':
return 0.0
try:
numeric = float(value)
return max(0.0, min(1.0, numeric))
except (TypeError, ValueError):
return 0.0
def _as_str_list(value) -> List[str]:
if isinstance(value, dict):
items = []
for key, item in value.items():
key_text = _as_str(key)
value_text = _as_str(item)
if key_text and value_text:
items.append(f'{key_text}: {value_text}')
elif key_text:
items.append(key_text)
elif value_text:
items.append(value_text)
return items
if not isinstance(value, list):
return []
return [_as_str(item) for item in value if item is not None]
def _normalize_entities(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for entity in value:
if not isinstance(entity, dict):
continue
items.append({
'name': _as_str(entity.get('name')),
'entity_type': _as_str(entity.get('entity_type')),
'description': _as_str(entity.get('description')),
})
return items
def _normalize_relations(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for relation in value:
if not isinstance(relation, dict):
continue
source = _as_str(relation.get('source_entity_name') or relation.get('subject', ''))
target = _as_str(relation.get('target_entity_name') or relation.get('object', ''))
rtype = _as_str(relation.get('relation_type') or relation.get('predicate', ''))
fact = _as_str(relation.get('fact'))
if not fact and source and rtype and target:
fact = f'{source} {rtype} {target}'
items.append({
'source_entity_name': source,
'target_entity_name': target,
'relation_type': rtype,
'fact': fact,
'qualifiers': _as_str_list(relation.get('qualifiers')),
'evidence': _as_str(relation.get('evidence')),
'confidence': _as_float(relation.get('confidence')),
'valid_at': _as_str(relation.get('valid_at')),
'invalid_at': _as_str(relation.get('invalid_at')),
})
return items
def _normalize_action_items(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for action in value:
if not isinstance(action, dict):
continue
items.append({
'task': _as_str(action.get('task')),
'assignee': _as_str(action.get('assignee')),
'deadline': _as_str(action.get('deadline')),
'status': _as_str(action.get('status')) or '待办',
'priority': _as_str(action.get('priority')) or '',
})
return items
def _normalize_decisions(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for decision in value:
if not isinstance(decision, dict):
continue
items.append({
'content': _as_str(decision.get('content')),
'proposer': _as_str(decision.get('proposer')),
'status': _as_str(decision.get('status')) or '已决',
})
return items
def _normalize_metrics(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for metric in value:
if not isinstance(metric, dict):
continue
items.append({
'metric_name': _as_str(metric.get('metric_name')),
'value': _as_str(metric.get('value')),
'target': _as_str(metric.get('target')),
'owner': _as_str(metric.get('owner')),
'trend': _as_str(metric.get('trend')),
'unit': _as_str(metric.get('unit')),
})
return items
def _normalize_departments(value) -> List[dict]:
if not isinstance(value, list):
return []
items = []
for dept in value:
if not isinstance(dept, dict):
continue
name = _as_str(dept.get('name'))
if not name:
continue
items.append({
'name': name,
'description': _as_str(dept.get('description')),
'projects': _as_str_list(dept.get('projects')),
})
return items

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +0,0 @@
from __future__ import annotations
from pathlib import Path
def read_text_auto(path: str | Path) -> str:
"""Read common Chinese transcript encodings with a small fallback chain."""
file_path = Path(path).expanduser().resolve()
encodings = ("utf-8-sig", "utf-8", "gb18030", "gbk")
last_error: Exception | None = None
for encoding in encodings:
try:
return file_path.read_text(encoding=encoding)
except UnicodeDecodeError as exc:
last_error = exc
raise UnicodeDecodeError(
"unknown",
b"",
0,
1,
f"Unable to decode {file_path}: {last_error}",
)
def ensure_dir(path: str | Path) -> Path:
directory = Path(path)
directory.mkdir(parents=True, exist_ok=True)
return directory

View File

@ -1,640 +0,0 @@
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
from typing import Any
from .io_utils import ensure_dir
from .models import ExtractedMeeting
class LedgerStore:
def __init__(self, root: str | Path = "data") -> None:
self.root = Path(root).resolve()
ensure_dir(self.root)
def import_meeting(self, team_id: str, meeting: ExtractedMeeting) -> dict[str, Any]:
team_dir = ensure_dir(self.root / team_id)
state_path = team_dir / "state.json"
meetings_dir = ensure_dir(team_dir / "meetings")
state = self._load_state(state_path, team_id)
meeting_dict = meeting.to_dict()
existing_ids = {item.get("meeting_id") for item in state.get("meetings", [])}
if meeting.meeting_id in existing_ids:
state["meetings"] = [
meeting_dict if item.get("meeting_id") == meeting.meeting_id else item
for item in state.get("meetings", [])
]
imported = False
else:
state["meetings"].append(meeting_dict)
imported = True
state["last_meeting_date"] = meeting.meeting_date
state["last_updated"] = date.today().isoformat()
state["version"] = int(state.get("version", 0)) + (1 if imported else 0)
state = self._compact_state(state)
state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
(meetings_dir / f"{meeting.meeting_id}.json").write_text(
json.dumps(meeting_dict, ensure_ascii=False, indent=2),
encoding="utf-8",
)
ledger_path = team_dir / "team_ledger.md"
ledger_path.write_text(self.render_markdown(state), encoding="utf-8")
return {
"team_id": team_id,
"knowledge_base_id": team_id,
"meeting_id": meeting.meeting_id,
"ledger_path": str(ledger_path),
"state_path": str(state_path),
"summary": meeting.summary,
"imported": imported,
"counts": {
"projects": len(meeting.projects),
"metrics": len(meeting.metrics),
"actions": len(meeting.action_items),
"risks": len(meeting.risks),
"completed": len(meeting.completed_items),
"followups": len(meeting.followups),
},
}
def query(self, team_id: str, query: str, *, limit: int = 8) -> dict[str, Any]:
resolved_team = self.resolve_team_id(team_id)
if resolved_team is None:
teams = self.list_team_ids()
if teams:
return {
"answer": f"未找到知识库 `{team_id}` 的台账。当前已有知识库:" + "".join(teams) + "。请在问题里指定知识库名。",
"matches": [],
"available_knowledge_bases": teams,
}
return {"answer": "当前 data 目录下还没有任何知识库台账,请先导入会议。", "matches": [], "available_knowledge_bases": []}
team_dir = self.root / resolved_team
state = json.loads((team_dir / "state.json").read_text(encoding="utf-8"))
ledger_path = team_dir / "team_ledger.md"
if _looks_like_recent_meeting_query(query):
return self._recent_meeting_answer(state, ledger_path)
haystacks = self._flatten_state(state)
keywords = [word for word in _tokenize_query(query) if word]
scored: list[tuple[int, dict[str, str]]] = []
for item in haystacks:
score = sum(item["search_text"].count(word) for word in keywords)
if score:
scored.append((score, item))
scored.sort(key=lambda pair: pair[0], reverse=True)
matches = [item for _, item in scored[:limit]]
if matches:
answer = "根据当前会议台账,找到这些相关内容:\n" + "\n".join(
f"- [{item['type']}] {item['display_text']}" for item in matches
)
else:
answer = "台账中没有命中明显相关内容。可以换成项目名、负责人、指标名、风险关键词或时间要求再问。"
return {
"answer": answer,
"matches": matches,
"ledger_path": str(ledger_path),
"team_id": resolved_team,
"knowledge_base_id": resolved_team,
}
def list_team_ids(self) -> list[str]:
if not self.root.exists():
return []
return sorted(path.name for path in self.root.iterdir() if path.is_dir() and (path / "state.json").exists())
def resolve_team_id(self, team_id: str | None) -> str | None:
teams = self.list_team_ids()
if team_id and team_id != "default_team" and team_id in teams:
return team_id
if team_id and team_id != "default_team" and (self.root / team_id / "state.json").exists():
return team_id
if (team_id == "default_team" or not team_id) and len(teams) == 1:
return teams[0]
return None
def build_agent_context(self, team_id: str) -> str:
resolved = self.resolve_team_id(team_id)
if resolved is None:
return ""
state = json.loads((self.root / resolved / "state.json").read_text(encoding="utf-8"))
lines = []
for project in state.get("projects", [])[:20]:
lines.append(
f"- 项目: {project.get('name', '')} | 状态: {project.get('status', '待确认')} | "
f"最近更新: {project.get('latest_update', '')} | 最近提及: {project.get('last_meeting_date', '')}"
)
recent_meetings = state.get("meetings", [])[-5:]
if recent_meetings:
lines.append("")
lines.append("最近会议摘要:")
for meeting in recent_meetings:
title = meeting.get("title") or Path(meeting.get("source_path", "")).stem
summary = "".join(meeting.get("summary", [])[:2])
lines.append(f"- {meeting.get('meeting_date', '')} | {title} | {summary}")
return "\n".join(lines)
def render_markdown(self, state: dict[str, Any]) -> str:
latest = state["meetings"][-1] if state.get("meetings") else {}
knowledge_base_id = state.get("knowledge_base_id") or state["team_id"]
lines = [
"---",
f"knowledge_base_id: {knowledge_base_id}",
f"team_id: {state['team_id']}",
"doc_type: meeting_project_ledger",
f"last_meeting_date: {state.get('last_meeting_date', '')}",
f"last_updated: {state.get('last_updated', '')}",
f"version: {state.get('version', 0)}",
f"source_meeting_id: {latest.get('meeting_id', '')}",
"---",
"",
f"# {knowledge_base_id} 会议知识台账",
"",
"## 1. 当前总览",
"",
f"- 当前维护项目数:{len(state.get('projects', []))}",
f"- 最近一次会议:{state.get('last_meeting_date', '')}",
]
if latest.get("summary"):
lines.append(f"- 最近会议摘要:{''.join(latest.get('summary', [])[:3])}")
lines.extend(["", "## 2. 项目索引", "", "| 项目 | 当前状态 | 最近提及 | 最新更新 |", "|---|---|---|---|"])
projects = state.get("projects", [])
if projects:
for project in projects:
lines.append(
"| "
+ " | ".join(
_escape_md(str(value))
for value in (
project.get("name", ""),
project.get("status", "待确认"),
project.get("last_meeting_date", ""),
project.get("latest_update", ""),
)
)
+ " |"
)
else:
lines.append("| 暂无 | | | |")
lines.extend(["", "## 3. 项目详情", ""])
if not projects:
lines.append("暂无项目记录。")
for index, project in enumerate(projects, start=1):
lines.extend(
[
f"### 3.{index} {project.get('name', '')}",
"",
f"- 当前状态:{project.get('status', '待确认')}",
f"- 最近提及:{project.get('last_meeting_date', '')}",
f"- 最新更新:{project.get('latest_update', '') or project.get('summary', '') or '待确认'}",
]
)
if project.get("summary"):
lines.append(f"- 项目说明:{project.get('summary')}")
if project.get("keywords"):
lines.append(f"- 检索关键词:{''.join(project.get('keywords', []))}")
lines.extend(["", "#### 指标", "", "| 指标 | 当前值 | 目标 | 状态 | 趋势 | 备注 |", "|---|---|---|---|---|---|"])
metrics = project.get("metrics", [])
if metrics:
for metric in metrics:
lines.append(
"| "
+ " | ".join(
_escape_md(str(metric.get(key, "")))
for key in ("name", "value", "target", "status", "trend", "note")
)
+ " |"
)
else:
lines.append("| 暂无 | | | | | |")
lines.extend(["", "#### 待办", "", "| 事项 | 责任对象 | 截止时间 | 状态 | 下一步 |", "|---|---|---|---|---|"])
actions = project.get("actions", [])
if actions:
for action in actions:
lines.append(
"| "
+ " | ".join(
_escape_md(str(action.get(key, "")))
for key in ("title", "owner", "deadline", "status", "next_step")
)
+ " |"
)
else:
lines.append("| 暂无 | | | | |")
lines.extend(["", "#### 风险", "", "| 风险/问题 | 当前状态 | 影响 | 责任对象 | 处理动作 |", "|---|---|---|---|---|"])
risks = project.get("risks", [])
if risks:
for risk in risks:
lines.append(
"| "
+ " | ".join(
_escape_md(str(risk.get(key, "")))
for key in ("title", "status", "impact", "owner", "next_step")
)
+ " |"
)
else:
lines.append("| 暂无 | | | | |")
lines.extend(["", "#### 已完成", ""])
completed = project.get("completed", [])
if completed:
for item in completed:
lines.append(f"- {item.get('title', '')}{item.get('owner', '待确认')}{item.get('status', '已完成')}")
else:
lines.append("- 暂无。")
lines.extend(["", "#### 决策", ""])
decisions = project.get("decisions", [])
if decisions:
lines.extend(f"- {item}" for item in decisions)
else:
lines.append("- 暂无。")
lines.extend(["", "#### 下一步", ""])
next_steps = project.get("next_steps", [])
if next_steps:
lines.extend(f"- {item}" for item in next_steps)
else:
lines.append("- 暂无。")
lines.extend(["", "#### 更新历史", ""])
history = project.get("history", [])[-6:]
if history:
for item in reversed(history):
lines.append(
f"- {item.get('meeting_date', '')} | {item.get('status', '待确认')} | {item.get('update', '')}"
)
else:
lines.append("- 暂无。")
lines.append("")
lines.extend(["## 4. 会议导入记录", "", "| 日期 | 标题 | meeting_id | 来源 |", "|---|---|---|---|"])
for meeting in state.get("meetings", [])[-20:]:
title = meeting.get("title") or Path(meeting.get("source_path", "")).stem
lines.append(
"| "
+ " | ".join(
_escape_md(str(value))
for value in (
meeting.get("meeting_date", ""),
title,
meeting.get("meeting_id", ""),
meeting.get("source_path", ""),
)
)
+ " |"
)
lines.append("")
return "\n".join(lines)
def _recent_meeting_answer(self, state: dict[str, Any], ledger_path: Path) -> dict[str, Any]:
meetings = state.get("meetings", [])
if not meetings:
return {
"answer": "该知识库台账存在,但还没有会议导入记录。",
"matches": [],
"ledger_path": str(ledger_path),
"team_id": state.get("team_id", ""),
"knowledge_base_id": state.get("knowledge_base_id") or state.get("team_id", ""),
}
latest = meetings[-1]
title = latest.get("title") or Path(latest.get("source_path", "")).stem
parts = [
f"最近一次会议:{latest.get('meeting_date', '')}",
f"会议标题:{title}",
f"来源文件:{latest.get('source_path', '')}",
"",
"会议摘要:",
]
parts.extend(f"- {item}" for item in latest.get("summary", [])[:6])
projects = latest.get("projects", [])
if projects:
parts.extend(["", "本次涉及项目:"])
for project in projects[:8]:
parts.append(
f"- {project.get('name', '')}:状态={project.get('status', '待确认')}"
f"更新={project.get('summary', '') or project.get('progress', '') or '待确认'}"
)
return {
"answer": "\n".join(parts),
"matches": [],
"ledger_path": str(ledger_path),
"team_id": state.get("team_id", ""),
"knowledge_base_id": state.get("knowledge_base_id") or state.get("team_id", ""),
"meeting_id": latest.get("meeting_id", ""),
}
def _flatten_state(self, state: dict[str, Any]) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
for project in state.get("projects", []):
project_name = str(project.get("name", ""))
result.append(
_search_item(
item_type="项目",
display_text=f"{project_name}|状态:{project.get('status', '待确认')}|更新:{project.get('latest_update', '')}",
project=project_name,
raw_values=[
project_name,
project.get("status", ""),
project.get("summary", ""),
project.get("latest_update", ""),
" ".join(project.get("keywords", [])),
],
)
)
for metric in project.get("metrics", []):
result.append(
_search_item(
item_type="指标",
display_text=(
f"{project_name}{metric.get('name', '')}={metric.get('value', '')}"
f"|目标:{metric.get('target', '')}|状态:{metric.get('status', '')}"
),
project=project_name,
raw_values=list(metric.values()),
)
)
for action in project.get("actions", []):
result.append(
_search_item(
item_type="待办",
display_text=(
f"{project_name}{action.get('title', '')}|责任:{action.get('owner', '待确认')}"
f"|截止:{action.get('deadline', '')}|状态:{action.get('status', '')}"
),
project=project_name,
raw_values=list(action.values()),
)
)
for risk in project.get("risks", []):
result.append(
_search_item(
item_type="风险",
display_text=(
f"{project_name}{risk.get('title', '')}|状态:{risk.get('status', '')}"
f"|影响:{risk.get('impact', '')}"
),
project=project_name,
raw_values=list(risk.values()),
)
)
for item in project.get("completed", []):
result.append(
_search_item(
item_type="完成",
display_text=f"{project_name}{item.get('title', '')}|责任:{item.get('owner', '待确认')}",
project=project_name,
raw_values=list(item.values()),
)
)
for item in project.get("decisions", []):
result.append(
_search_item(
item_type="决策",
display_text=f"{project_name}{item}",
project=project_name,
raw_values=[project_name, item],
)
)
for item in project.get("next_steps", []):
result.append(
_search_item(
item_type="下一步",
display_text=f"{project_name}{item}",
project=project_name,
raw_values=[project_name, item],
)
)
for meeting in state.get("meetings", []):
for item in meeting.get("summary", []):
result.append(
_search_item(
item_type="会议摘要",
display_text=f"{meeting.get('meeting_date', '')}{item}",
raw_values=[meeting.get("meeting_date", ""), item],
)
)
return result
def _load_state(self, state_path: Path, team_id: str) -> dict[str, Any]:
if state_path.exists():
state = json.loads(state_path.read_text(encoding="utf-8"))
else:
state = {
"team_id": team_id,
"knowledge_base_id": team_id,
"version": 0,
"last_updated": "",
"last_meeting_date": "",
"meetings": [],
}
state.setdefault("projects", [])
state.setdefault("current_metrics", [])
state.setdefault("open_actions", [])
state.setdefault("risks", [])
state.setdefault("completed_items", [])
state.setdefault("followups", [])
return state
def _compact_state(self, state: dict[str, Any]) -> dict[str, Any]:
project_index: dict[str, dict[str, Any]] = {}
for meeting in state.get("meetings", []):
for raw_project in meeting.get("projects", []):
project = _normalize_project_state(raw_project)
marker = _project_key(project.get("name", ""), project.get("aliases", []))
if not marker:
continue
existing = project_index.get(marker)
if existing is None:
existing = {
"name": project.get("name", ""),
"aliases": project.get("aliases", []),
"status": project.get("status", "待确认"),
"summary": project.get("summary", ""),
"latest_update": project.get("summary", "") or project.get("progress", ""),
"last_meeting_date": meeting.get("meeting_date", ""),
"last_meeting_id": meeting.get("meeting_id", ""),
"keywords": project.get("keywords", []),
"decisions": [],
"next_steps": [],
"metrics": [],
"actions": [],
"risks": [],
"completed": [],
"history": [],
}
project_index[marker] = existing
_merge_project(existing, project, meeting)
projects = sorted(
project_index.values(),
key=lambda item: (item.get("last_meeting_date", ""), item.get("name", "")),
reverse=True,
)
state["projects"] = projects
state["current_metrics"] = _limit_items(_collect_from_projects(projects, "metrics"), 60)
state["open_actions"] = _limit_items([item for item in _collect_from_projects(projects, "actions") if item.get("status") != "已完成"], 120)
state["risks"] = _limit_items(_collect_from_projects(projects, "risks"), 100)
state["completed_items"] = _limit_items(_collect_from_projects(projects, "completed"), 120)
state["followups"] = _limit_items(
[
{
"title": item.get("title", ""),
"owner": item.get("owner", ""),
"deadline": item.get("deadline", ""),
"project": item.get("project", ""),
"status": item.get("status", ""),
}
for item in state["open_actions"]
],
80,
)
return state
def _normalize_project_state(project: dict[str, Any]) -> dict[str, Any]:
return {
"name": str(project.get("name", "")).strip(),
"aliases": _unique_text(project.get("aliases", [])),
"status": str(project.get("status", "")).strip() or "待确认",
"summary": str(project.get("summary", "")).strip(),
"progress": str(project.get("progress", "")).strip(),
"keywords": _unique_text(project.get("keywords", [])),
"decisions": _unique_text(project.get("decisions", [])),
"next_steps": _unique_text(project.get("next_steps", [])),
"metrics": [dict(item) for item in project.get("metrics", []) if item.get("name")],
"actions": [dict(item) for item in project.get("actions", []) if item.get("title")],
"risks": [dict(item) for item in project.get("risks", []) if item.get("title")],
"completed": [dict(item) for item in project.get("completed", []) if item.get("title")],
}
def _merge_project(target: dict[str, Any], incoming: dict[str, Any], meeting: dict[str, Any]) -> None:
target["aliases"] = _unique_text(target.get("aliases", []) + incoming.get("aliases", []) + [incoming.get("name", "")])
target["status"] = incoming.get("status") or target.get("status", "待确认")
target["summary"] = incoming.get("summary") or target.get("summary", "")
target["latest_update"] = incoming.get("summary") or incoming.get("progress") or target.get("latest_update", "")
target["last_meeting_date"] = meeting.get("meeting_date", "") or target.get("last_meeting_date", "")
target["last_meeting_id"] = meeting.get("meeting_id", "") or target.get("last_meeting_id", "")
target["keywords"] = _unique_text(target.get("keywords", []) + incoming.get("keywords", []))
target["decisions"] = _unique_text(target.get("decisions", []) + incoming.get("decisions", []))
target["next_steps"] = _unique_text(target.get("next_steps", []) + incoming.get("next_steps", []) + [item.get("title", "") for item in incoming.get("actions", []) if item.get("title")])
target["metrics"] = _merge_named_entries(target.get("metrics", []), incoming.get("metrics", []), "name", incoming.get("name", ""))
target["actions"] = _merge_named_entries(target.get("actions", []), incoming.get("actions", []), "title", incoming.get("name", ""))
target["risks"] = _merge_named_entries(target.get("risks", []), incoming.get("risks", []), "title", incoming.get("name", ""))
target["completed"] = _merge_named_entries(target.get("completed", []), incoming.get("completed", []), "title", incoming.get("name", ""))
update = incoming.get("summary") or incoming.get("progress") or "本次会议有更新"
history_item = {
"meeting_date": meeting.get("meeting_date", ""),
"meeting_id": meeting.get("meeting_id", ""),
"status": incoming.get("status", "待确认"),
"update": update,
}
target["history"] = _append_unique_history(target.get("history", []), history_item)
def _merge_named_entries(current: list[dict[str, Any]], incoming: list[dict[str, Any]], key: str, project_name: str) -> list[dict[str, Any]]:
merged: dict[str, dict[str, Any]] = {}
for item in current + incoming:
marker = _normalize_name(str(item.get(key, "")))
if not marker:
continue
merged[marker] = {**merged.get(marker, {}), **item, "project": project_name}
return list(merged.values())
def _append_unique_history(history: list[dict[str, Any]], item: dict[str, Any]) -> list[dict[str, Any]]:
marker = (item.get("meeting_id", ""), item.get("update", ""))
output = [entry for entry in history if (entry.get("meeting_id", ""), entry.get("update", "")) != marker]
output.append(item)
return output[-12:]
def _collect_from_projects(projects: list[dict[str, Any]], key: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for project in projects:
for item in project.get(key, []):
items.append({**item, "project": project.get("name", "")})
return items
def _limit_items(items: list[dict[str, Any]], size: int) -> list[dict[str, Any]]:
return items[-size:]
def _search_item(*, item_type: str, display_text: str, raw_values: list[Any], project: str = "") -> dict[str, str]:
search_text = "".join(str(value) for value in raw_values if value)
if project:
search_text = f"{project}{search_text}"
return {
"type": item_type,
"project": project,
"display_text": display_text,
"search_text": search_text,
}
def _project_key(name: str, aliases: list[str]) -> str:
normalized = _normalize_name(name)
if normalized:
return normalized
for alias in aliases:
normalized = _normalize_name(alias)
if normalized:
return normalized
return ""
def _normalize_name(text: str) -> str:
return re.sub(r"[\W_]+", "", text).lower()
def _unique_text(values: list[Any]) -> list[str]:
seen: set[str] = set()
output: list[str] = []
for raw in values:
text = str(raw).strip()
if not text or text in seen:
continue
seen.add(text)
output.append(text)
return output
def _escape_md(text: str) -> str:
return text.replace("|", "\\|").replace("\n", " ")
def _tokenize_query(query: str) -> list[str]:
stop_words = {"查询", "团队", "team", "team_id", "哪些", "还有", "一下", "帮我", "关于", "知识库", "项目"}
words = [part for part in re.split(r"[\s、,.!?:=]+", query) if len(part) >= 2]
tokens: list[str] = []
for word in words:
if word in stop_words:
continue
tokens.append(word)
if re.search(r"[\u4e00-\u9fff]", word) and len(word) > 4:
for size in range(2, min(6, len(word)) + 1):
for index in range(0, len(word) - size + 1):
tokens.append(word[index : index + size])
return list(dict.fromkeys(tokens))
def _looks_like_recent_meeting_query(query: str) -> bool:
return any(word in query for word in ("最近", "最新", "上次")) and any(word in query for word in ("会议", "内容", "纪要", "摘要"))

View File

@ -1,393 +0,0 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Callable
from core_agent.config import apply_compat_env_aliases, build_core_agent_config, load_core_agent_env
from core_agent.exceptions import AgentCancelled
from providers.openai_compatible import OpenAICompatibleProvider
from providers.prompt_loader import load_prompt
from .models import ExtractedMeeting
SubagentProgressCallback = Callable[[dict[str, Any]], None]
class MeetingDigestAgent:
def __init__(self, workspace: str | Path) -> None:
self.workspace = Path(workspace).resolve()
prompt = load_prompt("meeting_digest")
self.system_prompt = prompt["system"]
self.instructions = prompt.get("instructions", "")
self.provider = self._build_provider()
def analyze(
self,
*,
transcript_text: str,
source_path: str | Path,
meeting_date: str,
knowledge_base_id: str,
existing_context: str = "",
progress_callback: SubagentProgressCallback | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> ExtractedMeeting:
source = str(Path(source_path).resolve())
meeting_id = _meeting_id(source, transcript_text)
if self.provider is None:
self._emit(
progress_callback,
kind="subagent_status",
phase="fallback",
text="Agent 未检测到可用模型,切换到降级整理。",
)
return _fallback_meeting(
meeting_id=meeting_id,
meeting_date=meeting_date,
source_path=source,
transcript_text=transcript_text,
)
self._emit(
progress_callback,
kind="subagent_status",
phase="prepare",
text="Agent 正在加载历史台账并构造整理提示。",
)
self._raise_if_cancelled(should_cancel)
user_message = self._build_user_message(
transcript_text=transcript_text,
source_path=source,
meeting_date=meeting_date,
knowledge_base_id=knowledge_base_id,
existing_context=existing_context,
)
self._emit(
progress_callback,
kind="subagent_status",
phase="call_model",
text="Agent 正在调用模型整理项目、待办、风险和指标。",
)
turn = None
for event in self.provider.stream_generate(
[
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_message},
],
[],
should_cancel=should_cancel,
):
self._raise_if_cancelled(should_cancel)
if event.type == "reasoning" and event.delta:
self._emit(
progress_callback,
kind="subagent_reasoning",
phase="stream",
text=event.delta,
)
elif event.type == "content" and event.delta:
self._emit(
progress_callback,
kind="subagent_output",
phase="stream",
text=event.delta,
)
elif event.type == "turn":
turn = event.turn
if turn is None:
raise RuntimeError("Meeting digest agent stream ended without a final turn")
self._raise_if_cancelled(should_cancel)
self._emit(
progress_callback,
kind="subagent_status",
phase="parse",
text="Agent 已完成分析,正在解析结构化结果。",
)
payload = _parse_json_object(turn.content or "")
self._emit(
progress_callback,
kind="subagent_status",
phase="parse_done",
text="Agent 已完成结构化解析,准备写入项目台账。",
)
return _meeting_from_payload(
payload,
meeting_id=meeting_id,
meeting_date=meeting_date,
source_path=source,
)
def _build_provider(self) -> OpenAICompatibleProvider | None:
load_core_agent_env(self.workspace)
apply_compat_env_aliases()
config = build_core_agent_config()
if not config.api_key or not config.base_url or not config.model:
return None
return OpenAICompatibleProvider(
model=config.model,
api_key=config.api_key,
base_url=config.base_url,
timeout=config.timeout,
temperature=min(config.temperature, 0.1),
)
def _build_user_message(
self,
*,
transcript_text: str,
source_path: str,
meeting_date: str,
knowledge_base_id: str,
existing_context: str,
) -> str:
context = existing_context.strip() or "暂无历史台账。请直接从本次会议中识别项目并建立初始状态。"
parts: list[str] = []
if self.instructions:
parts.append(self.instructions)
parts.append(
f"知识库: {knowledge_base_id}\n"
f"会议日期: {meeting_date}\n"
f"来源文件: {source_path}\n\n"
"当前历史台账摘要:\n"
f"{context}\n\n"
"会议原文:\n"
f"{transcript_text}"
)
return "\n\n---\n\n".join(parts)
def _emit(
self,
callback: SubagentProgressCallback | None,
*,
kind: str,
phase: str,
text: str,
) -> None:
if callback is None:
return
callback(
{
"channel": "meeting_digest",
"kind": kind,
"phase": phase,
"text": text,
}
)
def _raise_if_cancelled(self, should_cancel: Callable[[], bool] | None) -> None:
if should_cancel and should_cancel():
raise AgentCancelled("Agent run cancelled by user.")
def _meeting_from_payload(
payload: dict[str, Any],
*,
meeting_id: str,
meeting_date: str,
source_path: str,
) -> ExtractedMeeting:
title = _as_text(payload.get("meeting_title"))
overview = _as_text(payload.get("overview"))
summary = _normalize_text_list(payload.get("summary"))
if overview and overview not in summary:
summary.insert(0, overview)
projects = [_normalize_project(item) for item in _as_list(payload.get("projects"))]
projects = [item for item in projects if item["name"]]
metrics: list[dict[str, Any]] = []
actions: list[dict[str, Any]] = []
risks: list[dict[str, Any]] = []
completed_items: list[dict[str, Any]] = []
followups: list[dict[str, Any]] = []
evidence: list[dict[str, str]] = []
for project in projects:
project_name = project["name"]
for metric in project["metrics"]:
metrics.append({**metric, "project": project_name})
for action in project["actions"]:
entry = {**action, "project": project_name}
actions.append(entry)
if entry.get("status") != "已完成":
followups.append(entry)
for risk in project["risks"]:
risks.append({**risk, "project": project_name})
for item in project["completed"]:
completed_items.append({**item, "project": project_name})
for snippet in project["evidence"]:
evidence.append({"project": project_name, "text": snippet})
if not summary:
if projects:
summary = [f"本次会议识别到 {len(projects)} 个项目更新。"]
else:
summary = ["本次会议已导入,但未识别出明确项目。"]
return ExtractedMeeting(
meeting_id=meeting_id,
meeting_date=meeting_date,
source_path=source_path,
title=title,
overview=overview,
summary=summary,
projects=projects,
metrics=metrics,
action_items=actions,
risks=risks,
completed_items=completed_items,
followups=followups,
evidence=evidence,
)
def _normalize_project(raw: Any) -> dict[str, Any]:
item = raw if isinstance(raw, dict) else {}
actions = [_normalize_action(value) for value in _as_list(item.get("actions"))]
risks = [_normalize_risk(value) for value in _as_list(item.get("risks"))]
completed = [_normalize_completed(value) for value in _as_list(item.get("completed"))]
metrics = [_normalize_metric(value) for value in _as_list(item.get("metrics"))]
decisions = _normalize_text_list(item.get("decisions"))
next_steps = _normalize_text_list(item.get("next_steps"))
evidence = _normalize_text_list(item.get("evidence"))
aliases = _normalize_text_list(item.get("aliases"))
keywords = _normalize_text_list(item.get("keywords"))
return {
"name": _as_text(item.get("name")),
"status": _as_text(item.get("status")) or "待确认",
"summary": _as_text(item.get("summary")),
"progress": _as_text(item.get("progress")),
"decisions": decisions,
"next_steps": next_steps,
"metrics": [value for value in metrics if value["name"]],
"actions": [value for value in actions if value["title"]],
"risks": [value for value in risks if value["title"]],
"completed": [value for value in completed if value["title"]],
"keywords": keywords,
"aliases": aliases,
"evidence": evidence,
}
def _normalize_metric(raw: Any) -> dict[str, str]:
item = raw if isinstance(raw, dict) else {}
return {
"name": _as_text(item.get("name")),
"value": _as_text(item.get("value")),
"target": _as_text(item.get("target")),
"status": _as_text(item.get("status")),
"trend": _as_text(item.get("trend")),
"note": _as_text(item.get("note")),
"evidence": _as_text(item.get("evidence")),
}
def _normalize_action(raw: Any) -> dict[str, str]:
item = raw if isinstance(raw, dict) else {}
return {
"title": _as_text(item.get("title")),
"owner": _as_text(item.get("owner")),
"deadline": _as_text(item.get("deadline")),
"status": _as_text(item.get("status")) or "待跟进",
"next_step": _as_text(item.get("next_step")),
"evidence": _as_text(item.get("evidence")),
}
def _normalize_risk(raw: Any) -> dict[str, str]:
item = raw if isinstance(raw, dict) else {}
return {
"title": _as_text(item.get("title")),
"status": _as_text(item.get("status")) or "关注中",
"impact": _as_text(item.get("impact")),
"owner": _as_text(item.get("owner")),
"next_step": _as_text(item.get("next_step")),
"deadline": _as_text(item.get("deadline")),
"evidence": _as_text(item.get("evidence")),
}
def _normalize_completed(raw: Any) -> dict[str, str]:
item = raw if isinstance(raw, dict) else {}
return {
"title": _as_text(item.get("title")),
"status": _as_text(item.get("status")) or "已完成",
"owner": _as_text(item.get("owner")),
"evidence": _as_text(item.get("evidence")),
}
def _parse_json_object(text: str) -> dict[str, Any]:
cleaned = text.strip()
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", cleaned, flags=re.S)
if fenced:
cleaned = fenced.group(1)
if cleaned.startswith("{") and cleaned.endswith("}"):
return json.loads(cleaned)
match = re.search(r"\{.*\}", cleaned, flags=re.S)
if match:
return json.loads(match.group(0))
raise ValueError("Meeting digest agent did not return valid JSON")
def _fallback_meeting(
*,
meeting_id: str,
meeting_date: str,
source_path: str,
transcript_text: str,
) -> ExtractedMeeting:
lines = [line.strip("-* \t") for line in transcript_text.splitlines() if line.strip()]
important = [line for line in lines if len(line) >= 12][:12]
summary = important[:4] or ["本次会议已导入,当前环境未配置模型,暂未完成智能整理。"]
project = {
"name": Path(source_path).stem[:48] or "未命名项目",
"status": "待确认",
"summary": summary[0],
"progress": "",
"decisions": [],
"next_steps": summary[1:3],
"metrics": [],
"actions": [],
"risks": [],
"completed": [],
"keywords": [],
"aliases": [],
"evidence": important[:6],
}
return ExtractedMeeting(
meeting_id=meeting_id,
meeting_date=meeting_date,
source_path=source_path,
title=Path(source_path).stem,
overview=summary[0],
summary=summary,
projects=[project],
evidence=[{"text": line} for line in important[:6]],
)
def _normalize_text_list(value: Any) -> list[str]:
return [text for text in (_as_text(item) for item in _as_list(value)) if text]
def _as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _as_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _meeting_id(source: str, text: str) -> str:
import hashlib
digest = hashlib.sha1((source + "\n" + text[:4000]).encode("utf-8", errors="ignore")).hexdigest()[:10]
return f"meeting_{digest}"

View File

@ -0,0 +1,427 @@
import hashlib
import logging
from typing import Callable, List, Optional
from meeting_memory.config import config
from meeting_memory.dedup_helpers import resolve_with_similarity
from meeting_memory.extractor import (
MeetingExtraction,
extract_entities_from_text,
extract_facts_from_text,
extract_meeting_info as monolithic_extract,
)
from meeting_memory.extractor import (
resolve_entities_against_graph,
resolve_facts_against_graph,
)
from meeting_memory.graph_store import graph_store
from meeting_memory.meeting_state import MeetingStateStore
from meeting_memory.raw_store import raw_meeting_store
logger = logging.getLogger(__name__)
state_store = MeetingStateStore(config.state_path)
ProgressCallback = Callable[[int, int, str], None]
class MeetingProcessor:
def process_meeting_file(self, filepath: str, force: bool = False) -> Optional[str]:
with open(filepath, 'r', encoding='utf-8') as file_obj:
text = file_obj.read()
return self.process_meeting_text(text, force=force)
def process_meeting_text(
self,
text: str,
force: bool = False,
interactive: bool = True,
progress_callback: Optional[ProgressCallback] = None,
use_multistep_extraction: bool = True,
) -> Optional[str]:
def report(step: int, total: int, message: str) -> None:
if progress_callback:
progress_callback(step, total, message)
print(f'[{step}/{total}] {message}')
if use_multistep_extraction:
return self._process_multistep(text, force, interactive, report)
else:
return self._process_monolithic(text, force, interactive, report)
def _process_monolithic(
self, text: str, force: bool, interactive: bool,
report: Callable,
) -> Optional[str]:
total_steps = 7
report(1, total_steps, '计算内容哈希')
content_hash = self._compute_content_hash(text)
if not force and state_store.has_content_hash(content_hash):
logger.info('Duplicate content hash skipped: %s', content_hash[:12])
return None
if not force:
report(2, total_steps, 'Neo4j 语义相似去重检索')
similar = graph_store.find_similar_episode(text, threshold=0.92)
if similar:
meta = similar['metadata']
if not interactive:
logger.info('Skipped similar meeting: %s', meta.get('title', ''))
return None
print(f'\n发现相似会议:{meta.get("title", "")} ({meta.get("date", "")}) 相似度 {similar["score"]:.2%}')
while True:
choice = input('选择 [s]跳过 / [o]覆盖(默认 s').strip().lower() or 's'
if choice == 's':
logger.info('Skipped similar meeting: %s', meta.get('title', ''))
return None
if choice == 'o':
force = True
break
print('请输入 s 或 o。')
else:
report(2, total_steps, '跳过语义去重,按覆盖模式继续')
report(3, total_steps, '调用大模型抽取结构化信息(单步模式)')
meeting_data = self._extract_monolithic(text)
if not meeting_data:
logger.error('Failed to extract meeting information')
return None
data_dict = meeting_data.model_dump()
return self._finish_pipeline(data_dict, content_hash, text, force, interactive, report, total_steps)
def _process_multistep(
self, text: str, force: bool, interactive: bool,
report: Callable,
) -> Optional[str]:
total_steps = 10
report(1, total_steps, '计算内容哈希')
content_hash = self._compute_content_hash(text)
if not force and state_store.has_content_hash(content_hash):
logger.info('Duplicate content hash skipped: %s', content_hash[:12])
return None
if not force:
report(2, total_steps, 'Neo4j 语义相似去重检索')
similar = graph_store.find_similar_episode(text, threshold=0.92)
if similar:
meta = similar['metadata']
if not interactive:
logger.info('Skipped similar meeting: %s', meta.get('title', ''))
return None
print(f'\n发现相似会议:{meta.get("title", "")} ({meta.get("date", "")}) 相似度 {similar["score"]:.2%}')
while True:
choice = input('选择 [s]跳过 / [o]覆盖(默认 s').strip().lower() or 's'
if choice == 's':
logger.info('Skipped similar meeting: %s', meta.get('title', ''))
return None
if choice == 'o':
force = True
break
print('请输入 s 或 o。')
else:
report(2, total_steps, '跳过语义去重,按覆盖模式继续')
# Step 3: 提取标题、日期、参与人等元信息
report(3, total_steps, '抽取会议元信息(标题、日期、参与者等)')
meta_info = self._extract_monolithic(text, stream=interactive)
if not meta_info:
logger.error('Failed to extract meeting metadata')
return None
data_dict = meta_info.model_dump()
data_dict['_content_hash'] = content_hash
data_dict['_graph_meeting_id'] = graph_store.meeting_id(data_dict)
data_dict['_original_text'] = text
# Step 4: 抽取实体节点LLM 调用 1
report(4, total_steps, '第 1 步实体抽取:识别会议中提及的实体')
use_stream = interactive
previous_episodes = self._get_previous_episodes_context(data_dict)
extracted_entities = extract_entities_from_text(
text, previous_episodes=previous_episodes, stream=use_stream
)
logger.info('Extracted %d entities from meeting', len(extracted_entities))
if not extracted_entities:
logger.warning('No entities extracted, aborting')
return None
# Step 5: 实体去重(与已有图谱对比 + LLM 裁决)
report(5, total_steps, '实体去重:与图谱中已有实体对比')
resolved_entities = self._dedup_entities(extracted_entities, text)
data_dict['entities'] = resolved_entities
logger.info('After dedup: %d entities remain', len(resolved_entities))
# Step 6: 抽取事实关系LLM 调用 2
report(6, total_steps, '事实抽取:提取实体间的结构化关系')
reference_time = data_dict.get('date', '')
extracted_facts = extract_facts_from_text(
text, resolved_entities,
reference_time=reference_time,
previous_episodes=previous_episodes,
stream=use_stream,
)
logger.info('Extracted %d facts from meeting', len(extracted_facts))
# Step 7: 事实去重与矛盾检测
report(7, total_steps, '事实解析:去重与矛盾检测')
resolved_facts = self._dedup_facts(extracted_facts, data_dict)
data_dict['relations'] = resolved_facts
logger.info('After dedup: %d facts remain', len(resolved_facts))
# Step 8: 检查标题和日期重复
report(8, total_steps, '检查标题和日期重复')
should_skip = self._handle_duplicate(data_dict, force=force, interactive=interactive)
if should_skip:
return None
meeting_title = data_dict.get('title', '')
meeting_date = data_dict.get('date', '')
# Step 9: 归档 + 合并行动项/指标
report(9, total_steps, '归档和状态合并')
raw_path = raw_meeting_store.save(text, title=meeting_title, date=meeting_date)
data_dict['_original_text_path'] = raw_path
meeting_filename = f'{graph_store.meeting_id(data_dict)}.md'
data_dict['action_items'] = state_store.merge_action_items(
data_dict.get('action_items', []),
meeting_title, meeting_date, meeting_filename,
)
data_dict['metrics'] = state_store.merge_metrics(
data_dict.get('metrics', []),
meeting_title, meeting_date, meeting_filename,
)
state_store.add_content_hash(content_hash, meeting_title, meeting_date, meeting_filename)
state_store.save()
# Step 10: 写入 Neo4j
report(10, total_steps, '写入 Neo4j 图谱')
graph_store.upsert_meeting_subgraph(data_dict)
logger.info('Meeting processed (multi-step): %s', meeting_title)
return raw_path
def _get_previous_episodes_context(self, data_dict: dict) -> list:
meeting_title = data_dict.get('title', '')
meeting_date = data_dict.get('date', '')
series_info = state_store.get_series_info(meeting_title)
if not series_info:
return []
processed = series_info.get('processed_titles', [])
if not processed:
return []
rows = graph_store.run_query('''
MATCH (m:Meeting)
WHERE m.title IN $titles
OPTIONAL MATCH (m)-[:HAS_EPISODE]->(ep:Episode)
RETURN m.title AS title, m.date AS date, ep.summary AS summary, ep.content AS content
ORDER BY m.date DESC
LIMIT 3
''', titles=processed[-3:])
return [{'content': r.get('content', r.get('summary', '')), 'timestamp': r.get('date', '')} for r in rows]
def _dedup_entities(self, extracted: list, text: str) -> list:
try:
existing = graph_store.get_entities_map()
if not existing:
return extracted
existing_list = [
{
'candidate_id': i,
'name': v['name'],
'entity_type': v.get('entity_type', ''),
'summary': v.get('summary', '') or v.get('description', ''),
}
for i, v in enumerate(existing.values())
]
resolved_indices, unresolved_indices = resolve_with_similarity(
extracted, existing_list
)
logger.info(
'Deterministic dedup: %d resolved to existing, %d need LLM',
len(resolved_indices), len(unresolved_indices),
)
resolved_set = set(resolved_indices)
unresolved_set = set(unresolved_indices)
kept = [
e for i, e in enumerate(extracted)
if '_resolved_to' not in e
]
if unresolved_indices:
unresolved = [extracted[i] for i in unresolved_indices]
llm_resolved = resolve_entities_against_graph(
unresolved, existing_list, episode_content=text,
)
kept.extend(llm_resolved)
return kept
except Exception as exc:
logger.warning('Entity dedup failed, keeping all extracted: %s', exc)
return extracted
def _dedup_facts(self, facts: list, data_dict: dict) -> list:
resolved = []
for fact in facts:
try:
source = fact.get('source_entity_name', '')
target = fact.get('target_entity_name', '')
fact_text = fact.get('fact', '')
exact_existing = graph_store.get_facts_between(source, target)
hybrid_candidates = graph_store.search_related_facts_hybrid(
fact_text, limit=5
) if fact_text else []
invalidation_candidates = [
c for c in hybrid_candidates
if c.get('fact', '') not in {e.get('fact', '') for e in exact_existing}
]
all_existing = list(exact_existing)
seen_facts = {e.get('fact', '') for e in exact_existing}
for c in hybrid_candidates:
cf = c.get('fact', '')
if cf and cf not in seen_facts:
all_existing.append(c)
seen_facts.add(cf)
if not all_existing:
resolved.append(fact)
continue
result = resolve_facts_against_graph(
fact, all_existing, invalidation_candidates
)
if not isinstance(result, dict):
resolved.append(fact)
continue
if result.get('is_duplicate'):
logger.debug('Skipped duplicate fact: %s', fact.get('fact', ''))
continue
if result.get('is_contradicted'):
contradicted_indices = result.get('contradicted_facts', [])
for idx in contradicted_indices:
if idx < len(all_existing):
target_fact = all_existing[idx]
graph_store.mark_relation_expired(
source_name=target_fact.get('source_name', source),
target_name=target_fact.get('target_name', target),
relation_type=target_fact.get('relation_type', ''),
)
logger.info(
'Invalidated %d contradicted facts for: %s',
len(contradicted_indices), fact.get('fact', ''),
)
resolved.append(fact)
except Exception as exc:
logger.warning('Fact dedup failed, keeping: %s', exc)
resolved.append(fact)
return resolved
def _finish_pipeline(
self, data_dict: dict, content_hash: str, text: str,
force: bool, interactive: bool, report: Callable, total_steps: int,
) -> Optional[str]:
data_dict['_content_hash'] = content_hash
data_dict['_graph_meeting_id'] = graph_store.meeting_id(data_dict)
report(4, total_steps, '检查标题和日期重复')
should_skip = self._handle_duplicate(data_dict, force=force, interactive=interactive)
if should_skip:
return None
meeting_title = data_dict.get('title', '')
meeting_date = data_dict.get('date', '')
report(5, total_steps, '归档原始会议文本')
raw_path = raw_meeting_store.save(text, title=meeting_title, date=meeting_date)
data_dict['_original_text'] = text
data_dict['_original_text_path'] = raw_path
meeting_filename = f'{graph_store.meeting_id(data_dict)}.md'
report(6, total_steps, '合并行动项和指标状态')
data_dict['action_items'] = state_store.merge_action_items(
data_dict.get('action_items', []), meeting_title, meeting_date, meeting_filename,
)
data_dict['metrics'] = state_store.merge_metrics(
data_dict.get('metrics', []), meeting_title, meeting_date, meeting_filename,
)
state_store.add_content_hash(content_hash, meeting_title, meeting_date, meeting_filename)
state_store.save()
report(7, total_steps, '写入 Neo4j 图谱和检索数据')
graph_store.upsert_meeting_subgraph(data_dict)
logger.info('Meeting processed: %s', meeting_title)
return raw_path
def _handle_duplicate(self, data_dict: dict, force: bool, interactive: bool = True) -> bool:
title = data_dict.get('title', '')
date = data_dict.get('date', '')
existing = graph_store.get_meeting(title, date)
if not existing:
return False
if force:
logger.info('Duplicate meeting found; overwriting in force mode: %s', title)
self._remove_old(data_dict, existing)
return False
if not interactive:
logger.info('Skipped duplicate meeting in non-interactive mode: %s', title)
return True
print(f'\n发现重复会议:{title} ({date})')
while True:
choice = input('选择 [s]跳过 / [o]覆盖(默认 s').strip().lower() or 's'
if choice == 's':
logger.info('Skipped duplicate meeting: %s', title)
return True
if choice == 'o':
self._remove_old(data_dict, existing)
return False
print('请输入 s 或 o。')
def _remove_old(self, data_dict: dict, existing: Optional[dict] = None) -> None:
meeting_id = graph_store.meeting_id(data_dict)
graph_store.remove_meeting_subgraph(meeting_id)
new_hash = data_dict.get('_content_hash', '')
if new_hash:
state_store.remove_content_hash(new_hash)
if existing:
old_hash = existing.get('content_hash', '')
if old_hash and old_hash != new_hash:
state_store.remove_content_hash(old_hash)
logger.info('Removed old meeting artifacts: %s', data_dict.get('title', ''))
def _compute_content_hash(self, text: str) -> str:
normalized = text.strip().replace('\r\n', '\n')
return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
def _extract_monolithic(self, text: str, *, stream: bool = True) -> Optional[MeetingExtraction]:
try:
return monolithic_extract(text, stream=stream)
except Exception as exc:
logger.error('LLM extraction failed: %s', exc)
return None
def query(self, question: str, top_k: int = 3) -> str:
return graph_store.format_search_context(question, top_k=top_k)
def stats(self) -> dict:
return {
'graph': graph_store.get_stats(),
'state': state_store.get_stats(),
'raw_dir': config.storage.raw_dir,
'state_path': config.state_path,
}
meeting_processor = MeetingProcessor()

View File

@ -0,0 +1,187 @@
import hashlib
import json
import logging
import os
import re
from typing import List, Optional
logger = logging.getLogger(__name__)
def _item_id(task: str, assignee: str) -> str:
raw = f"{task}|{assignee}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()[:8]
def _metric_id(metric_name: str, owner: str) -> str:
raw = f"{metric_name}|{owner}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()[:8]
class MeetingStateStore:
def __init__(self, state_path: str):
self.state_path = state_path
self._state = self._load()
def _load(self) -> dict:
if os.path.exists(self.state_path):
try:
with open(self.state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as exc:
logger.warning("Failed to load state file, creating a new one: %s", exc)
return {
"action_items": {},
"metrics": {},
"meeting_series": {},
"content_hashes": {},
}
def save(self):
os.makedirs(os.path.dirname(self.state_path), exist_ok=True)
with open(self.state_path, "w", encoding="utf-8") as f:
json.dump(self._state, f, ensure_ascii=False, indent=2)
def _ensure_series(self, meeting_title: str, meeting_date: str) -> str:
series_name = self._detect_series(meeting_title)
series = self._state["meeting_series"].get(series_name)
if not series:
series = {"latest_date": meeting_date, "processed_titles": []}
self._state["meeting_series"][series_name] = series
if meeting_date > series.get("latest_date", ""):
series["latest_date"] = meeting_date
if meeting_title not in series["processed_titles"]:
series["processed_titles"].append(meeting_title)
return series_name
def _detect_series(self, title: str) -> str:
cleaned = re.sub(r"\uFF08\d{4}\u7B2C\w+\u671F\uFF09", "", title)
cleaned = re.sub(r"\(\d{4}\u7B2C\w+\u671F\)", "", cleaned)
cleaned = re.sub(r"\d{4}\u7B2C\w+\u671F", "", cleaned)
cleaned = re.sub(r"\d{4}\u5E74\u7B2C\w+\u6B21", "", cleaned)
cleaned = cleaned.strip("-_ ")
return cleaned or title
def merge_action_items(
self,
new_items: List[dict],
meeting_title: str,
meeting_date: str,
meeting_filename: str,
) -> List[dict]:
series_name = self._ensure_series(meeting_title, meeting_date)
merged = []
for item in new_items:
task = item.get("task", "")
assignee = item.get("assignee", "")
iid = _item_id(task, assignee)
history_entry = {
"date": meeting_date,
"meeting": meeting_filename,
"status": item.get("status", "待办"),
"priority": item.get("priority", ""),
"deadline": item.get("deadline", ""),
}
existing = self._state["action_items"].get(iid)
if existing:
existing["history"].append(history_entry)
existing["latest"] = history_entry
latest = existing["history"][-1]
item["_item_id"] = iid
item["_history"] = list(existing["history"])
item["status"] = latest["status"]
item["priority"] = latest["priority"]
item["deadline"] = latest["deadline"]
else:
self._state["action_items"][iid] = {
"item_id": iid,
"task": task,
"assignee": assignee,
"series": series_name,
"created_meeting": meeting_filename,
"history": [history_entry],
"latest": history_entry,
}
item["_item_id"] = iid
item["_history"] = [history_entry]
merged.append(item)
return merged
def merge_metrics(
self,
new_metrics: List[dict],
meeting_title: str,
meeting_date: str,
meeting_filename: str,
) -> List[dict]:
merged = []
for metric in new_metrics:
metric_name = metric.get("metric_name", "")
owner = metric.get("owner", "")
mid = _metric_id(metric_name, owner)
history_entry = {
"date": meeting_date,
"meeting": meeting_filename,
"value": metric.get("value", ""),
"target": metric.get("target", ""),
"trend": metric.get("trend", ""),
}
existing = self._state["metrics"].get(mid)
if existing:
existing["history"].append(history_entry)
existing["latest"] = history_entry
metric["_metric_id"] = mid
metric["_history"] = list(existing["history"])
else:
self._state["metrics"][mid] = {
"metric_id": mid,
"metric_name": metric_name,
"owner": owner,
"history": [history_entry],
"latest": history_entry,
}
metric["_metric_id"] = mid
metric["_history"] = [history_entry]
merged.append(metric)
return merged
def get_action_item_history(self, item_id: str) -> Optional[dict]:
return self._state["action_items"].get(item_id)
def get_metric_history(self, metric_id: str) -> Optional[dict]:
return self._state["metrics"].get(metric_id)
def get_series_info(self, title: str) -> Optional[dict]:
series_name = self._detect_series(title)
return self._state["meeting_series"].get(series_name)
def has_content_hash(self, content_hash: str) -> bool:
return content_hash in self._state["content_hashes"]
def add_content_hash(self, content_hash: str, title: str, date: str, filename: str):
self._state["content_hashes"][content_hash] = {
"title": title,
"date": date,
"filename": filename,
}
def remove_content_hash(self, content_hash: str):
self._state["content_hashes"].pop(content_hash, None)
def get_stats(self) -> dict:
return {
"action_items_tracked": len(self._state["action_items"]),
"metrics_tracked": len(self._state["metrics"]),
"meeting_series": len(self._state["meeting_series"]),
"content_hashes": len(self._state["content_hashes"]),
}

View File

@ -1,38 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class ExtractedMeeting:
meeting_id: str
meeting_date: str
source_path: str
title: str = ""
overview: str = ""
summary: list[str] = field(default_factory=list)
projects: list[dict[str, Any]] = field(default_factory=list)
metrics: list[dict[str, Any]] = field(default_factory=list)
action_items: list[dict[str, Any]] = field(default_factory=list)
risks: list[dict[str, Any]] = field(default_factory=list)
completed_items: list[dict[str, Any]] = field(default_factory=list)
followups: list[dict[str, Any]] = field(default_factory=list)
evidence: list[dict[str, str]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"meeting_id": self.meeting_id,
"meeting_date": self.meeting_date,
"source_path": self.source_path,
"title": self.title,
"overview": self.overview,
"summary": self.summary,
"projects": self.projects,
"metrics": self.metrics,
"action_items": self.action_items,
"risks": self.risks,
"completed_items": self.completed_items,
"followups": self.followups,
"evidence": self.evidence,
}

View File

@ -0,0 +1,5 @@
from .extract_nodes import extract_entities
from .extract_edges import extract_facts
from .dedupe_nodes import resolve_entities
from .dedupe_edges import resolve_facts
from .summarize_nodes import summarize_entity

View File

@ -0,0 +1,49 @@
from typing import Any
def resolve_facts(context: dict[str, Any]) -> list[dict]:
existing_facts = context.get('existing_facts', [])
new_fact = context.get('new_fact', '')
invalidation_candidates = context.get('invalidation_candidates', [])
existing_text = '\n'.join(
f' [idx={i}] {f.get("fact", "")}' for i, f in enumerate(existing_facts)
)
invalidation_text = '\n'.join(
f' [idx={i + len(existing_facts)}] {f.get("fact", "")}'
for i, f in enumerate(invalidation_candidates)
)
user_prompt = f"""
<已有事实>
{existing_text}
</已有事实>
<事实失效候选>
{invalidation_text}
</事实失效候选>
<新事实>
{new_fact}
</新事实>
注意idx 编号是连续的已有事实从 0 开始失效候选紧随其后
任务
1. **重复检测**如果<新事实><已有事实>中的某条描述的是完全相同的客观事实返回该 idx
2. **矛盾检测**如果<新事实><已有事实><失效候选>中的某条相互矛盾如状态已更新数值已变更返回该 idx
返回格式
{{"duplicate_facts": [idx列表], "contradicted_facts": [idx列表]}}
如果没有重复或矛盾返回空列表
示例
- 新事实"张三负责宽带运维项目" vs 已有"张三负责宽带运维" 重复相同事实
- 新事实"宽带用户数当前值 8500" vs 已有"宽带用户数目标值 10000" 不重复不矛盾数值维度不同
- 新事实"宽带用户数当前值 9000" vs 已有"宽带用户数 8000" 矛盾同一指标数值更新
"""
return [
{'role': 'system', 'content': '你是事实去重和矛盾检测助手。判断新事实与已有事实的关系。'},
{'role': 'user', 'content': user_prompt},
]

View File

@ -0,0 +1,49 @@
from typing import Any
def resolve_entities(context: dict[str, Any]) -> list[dict]:
extracted = context.get('extracted_entities', [])
existing = context.get('existing_entities', [])
episode_content = context.get('episode_content', '')
extracted_text = '\n'.join(
f' [{i}] {e.get("name", "")}{e.get("entity_type", "未知")}{e.get("description", "")}'
for i, e in enumerate(extracted)
)
existing_text = '\n'.join(
f' [candidate_id={c.get("candidate_id", i)}] {c.get("name", "")}{c.get("entity_type", "未知")}{c.get("summary", "")[:100]}'
for i, c in enumerate(existing)
)
user_prompt = f"""
<当前会议内容>
{episode_content}
</当前会议内容>
<新抽取的实体>
{extracted_text}
</新抽取的实体>
<图谱中已有的实体>
{existing_text}
</图谱中已有的实体>
任务判断<新抽取的实体>中的每一个是否与<图谱中已有的实体>中的某个是同一个真实世界对象
判断标准
- **是重复**两个名称指向同一个真实世界的人组织地点项目指标等
- **不是重复**名称相似但指向不同实体如两个同名但不同的人同名的不同项目
对每个新抽取的实体返回
- id: 对应新抽取实体列表中的序号
- name: 实体的最佳名称优先使用已有实体中的更完整名称
- duplicate_candidate_id: 匹配到的已有实体的 candidate_id如果无匹配则填 -1
返回格式 JSON 数组[{{"id": 0, "name": "张三", "duplicate_candidate_id": -1}}, ...]
必须为新抽取的每个实体返回一条记录id 0 开始连续编号
"""
return [
{'role': 'system', 'content': '你是实体去重助手。判断两个实体是否指向同一个真实世界对象。'},
{'role': 'user', 'content': user_prompt},
]

View File

@ -0,0 +1,66 @@
from typing import Any
def extract_facts(context: dict[str, Any]) -> list[dict]:
previous = context.get('previous_episodes', [])
current = context.get('episode_content', '')
entities = context.get('entities', [])
reference_time = context.get('reference_time', '')
previous_section = ''
if previous:
import json
previous_section = f'\n<历史上下文>\n{json.dumps(previous, ensure_ascii=False)}\n</历史上下文>\n'
entities_text = '\n'.join(
f' [{i}] {e.get("name", "")}{e.get("entity_type", "未知")}' for i, e in enumerate(entities)
)
user_prompt = f"""
{previous_section}
<当前会议内容>
{current}
</当前会议内容>
<已抽取实体>
{entities_text}
</已抽取实体>
<参考时间>
{reference_time}
</参考时间>
抽取规则
1. <当前会议内容>中抽取上述<已抽取实体>之间的**事实关系**
2. 每条关系必须涉及两个**不同**的实体
3. 返回 JSON 数组格式
[{{
"source_entity_name": "源实体名称(必须来自上方的实体列表)",
"target_entity_name": "目标实体名称(必须来自上方的实体列表)",
"relation_type": "关系类型,如 负责、汇报、隶属于、参与、目标值、截止于、影响、依赖于",
"fact": "一句自然语言的事实描述,保留原文中所有具体细节(数值、时间、地点等)",
"valid_at": "该事实开始成立的时间ISO 8601格式如 2025-04-30T00:00:00Z不明确则留空",
"invalid_at": "该事实不再成立的时间,不明确则留空",
"evidence": "原文中的关键证据短句",
"qualifiers": ["限定条件列表,如数值、范围、状态、截止时间等"],
"confidence": 置信度0到1之间
}}]
4. relation_type 避免使用"关联""涉及"等空泛词优先使用具体谓词
负责汇报目标值当前值低于高于要求督导推进支撑依赖计划完成截止于参与隶属于分管协调审批
5. 层次关系结构隶属使用以下固定 relation_type
HAS_PROJECT: 部门管辖项目Department -> Project
HAS_METRIC: 项目拥有指标Project -> Metric
PART_OF: 实体属于某个上级实体
6. 同一对实体之间可能既有层次关系HAS_PROJECT也有事实关系负责汇报需要分别抽取
7. fact 必须是一句完整的自然语言事实保留所有具体信息人名数值产品名地点等
8. 如果根据上下文可以判断事实的开始/结束时间填入 valid_at / invalid_at
"""
return [
{'role': 'system', 'content': '你是一个专业的事实关系抽取专家。从会议记录中抽取实体间的结构化事实关系。'},
{'role': 'user', 'content': user_prompt},
]

View File

@ -0,0 +1,56 @@
from typing import Any
SYSTEM_PROMPT = (
'你是会议纪要实体抽取专家。'
'从会议记录中抽取明确的实体节点包括部门Department、项目Project、指标Metric、人物Person、系统System、文档Document等。'
'不要抽取抽象概念、情感、时间日期或泛泛的名词。'
)
def extract_entities(context: dict[str, Any]) -> list[dict]:
previous = context.get('previous_episodes', [])
current = context.get('episode_content', '')
entity_types = context.get('entity_types', [])
entity_types_section = ''
if entity_types:
entity_types_section = '\n'.join(
f' - {t["type"]}: {t["description"]}' for t in entity_types
)
else:
entity_types_section = ' - 未限定类型,请根据上下文自行判断'
previous_section = ''
if previous:
import json
previous_section = f'\n<历史上下文>\n{json.dumps(previous, ensure_ascii=False)}\n</历史上下文>\n'
user_prompt = f"""
{previous_section}
<当前会议内容>
{current}
</当前会议内容>
<实体类型>
{entity_types_section}
</实体类型>
抽取规则
1. 只抽取当前会议内容中**明确提及**的实体
2. 每个实体必须是有唯一标识的具体事物人名组织名地名项目名指标名称等
3. 不要抽取代词抽象概念增长改善风险时间日期
4. 如果同一实体在不同来源中以不同名称出现如简称/全称保留最完整的形式
5. 必须返回 JSON 数组格式[{{"name": "实体名称", "entity_type": "类型", "description": "描述", "evidence": "原文证据"}}]
6. description 写一段对该实体的简要描述20字以内
7. evidence 从原文中摘录提及该实体的关键短句
注意实体类型建议使用 Department部门Project项目Metric指标Person人物System系统Document文档请确保
- 部门Department会议中提到的具体部门名称"技术部""市场部"
- 项目Project部门负责的具体项目名称
- 指标Metric项目中提到的具体量化指标"响应时间""完成率"
"""
return [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_prompt},
]

View File

@ -0,0 +1,41 @@
from typing import Any
def summarize_entity(context: dict[str, Any]) -> list[dict]:
entity_name = context.get('entity_name', '')
existing_summary = context.get('existing_summary', '')
episodes = context.get('episodes', [])
previous = context.get('previous_episodes', [])
existing_section = ''
if existing_summary:
existing_section = f'\n<已有摘要>\n{existing_summary}\n</已有摘要>\n'
previous_section = ''
if previous:
import json
previous_section = f'\n<历史内容>\n{json.dumps(previous, ensure_ascii=False)}\n</历史内容>\n'
episodes_text = '\n---\n'.join(episodes) if isinstance(episodes, list) else episodes
user_prompt = f"""
{previous_section}
<当前内容>
{episodes_text}
</当前内容>
{existing_section}
为实体 **{entity_name}** 生成一段信息密集的摘要
规则
1. 只使用<当前内容><已有摘要>中的事实不要推测
2. 保留所有实质性的人名角色地点日期数值
3. 用第三人称直接陈述事实
4. 不要使用"提及了""讨论了""指出"等元语言动词直接陈述事实
5. 如果会议对已有信息做了更新采用更新的说法
6. 摘要不超过 500
7. 返回 JSON{{"summary": "摘要内容"}}
"""
return [
{'role': 'system', 'content': '你是实体摘要助手。根据会议内容为实体生成信息密集的摘要。'},
{'role': 'user', 'content': user_prompt},
]

View File

@ -0,0 +1,55 @@
import logging
import os
from datetime import datetime
from meeting_memory.config import config
logger = logging.getLogger(__name__)
def _sanitize_filename(name: str) -> str:
if not name:
return "untitled"
invalid = '<>:"/\\|?*'
for char in invalid:
name = name.replace(char, "")
name = name.replace(" ", "_").strip("._")
return name or "untitled"
class RawMeetingStore:
def __init__(self):
self.raw_dir = config.storage.raw_dir
os.makedirs(self.raw_dir, exist_ok=True)
def save(self, text: str, title: str = "", date: str = "") -> str:
os.makedirs(self.raw_dir, exist_ok=True)
date_str = date or datetime.now().strftime("%Y-%m-%d")
safe_date = _sanitize_filename(date_str)[:40]
safe_title = _sanitize_filename(title)[:60]
filename = f"{safe_date}_{safe_title}.md"
filepath = os.path.join(self.raw_dir, filename)
content = "\n".join(
[
"---",
f'title: "{title}"',
f'date: "{date_str}"',
"status: archived",
"---",
"",
f"# {title or 'Untitled Meeting'}",
"",
text,
"",
]
)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
logger.info("Saved raw meeting text: %s", filepath)
return filepath
raw_meeting_store = RawMeetingStore()

View File

@ -0,0 +1,142 @@
import logging
import re
from collections import Counter
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _cosine_similarity(left: List[float], right: List[float]) -> float:
if not left or not right or len(left) != len(right):
return 0.0
dot = sum(a * b for a, b in zip(left, right))
left_norm = sum(a * a for a in left) ** 0.5
right_norm = sum(b * b for b in right) ** 0.5
if left_norm == 0 or right_norm == 0:
return 0.0
return dot / (left_norm * right_norm)
def _keyword_terms(text: str) -> List[str]:
normalized = (text or '').lower()
raw_terms = re.findall(r'[a-z0-9]+|[\u4e00-\u9fff]{2,}', normalized)
stopwords = {'是什么', '多少', '分别', '以及', '还有', '当前值', '目标值'}
terms: List[str] = []
for raw in raw_terms:
if raw in stopwords:
continue
if raw not in terms:
terms.append(raw)
if re.fullmatch(r'[\u4e00-\u9fff]{4,}', raw):
for size in (2, 3, 4):
for idx in range(0, len(raw) - size + 1):
piece = raw[idx: idx + size]
if piece not in stopwords and piece not in terms:
terms.append(piece)
return terms
def reciprocal_rank_fusion(
ranked_lists: List[List[Dict[str, Any]]],
k: int = 60,
key: str = 'text',
) -> List[Dict[str, Any]]:
if not ranked_lists:
return []
scores: Dict[str, Dict] = {}
for ranked in ranked_lists:
for rank, item in enumerate(ranked):
item_id = item.get(key, str(id(item)))
if item_id not in scores:
enriched = dict(item)
enriched['_fusion_scores'] = []
scores[item_id] = {'item': enriched, 'score': 0.0}
scores[item_id]['score'] += 1.0 / (k + rank + 1)
scores[item_id]['item']['_fusion_scores'].append(1.0 / (k + rank + 1))
sorted_items = sorted(
scores.values(),
key=lambda x: (-x['score'], x['item'].get('title', '')),
)
results = []
for entry in sorted_items:
item = entry['item']
item['score'] = round(entry['score'], 4)
results.append(item)
return results
def _keyword_score(text: str, question: str) -> float:
source = (text or '').lower()
terms = _keyword_terms(question)
if not source or not terms:
return 0.0
hits = sum(1 for term in terms if term in source)
return hits / len(terms)
def bm25_score(
query_terms: List[str],
doc_freqs: Dict[str, int],
doc_term_counts: Dict[str, int],
doc_length: float,
avg_doc_length: float,
total_docs: int,
k1: float = 1.5,
b: float = 0.75,
) -> float:
score = 0.0
for term in query_terms:
tf = doc_term_counts.get(term, 0)
if tf == 0:
continue
df = doc_freqs.get(term, 1)
idf = ((total_docs - df + 0.5) / (df + 0.5) + 1.0)
if idf <= 0:
continue
tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_length / avg_doc_length))
score += idf * tf_norm
return score
def search_bfs(
seed_entities: List[str],
get_edges_fn,
max_depth: int = 2,
max_nodes: int = 50,
) -> Dict[str, Any]:
if not seed_entities:
return {'nodes': [], 'edges': [], 'paths': []}
visited = set(seed_entities)
queue = [(entity, 0) for entity in seed_entities]
collected_edges: List[Dict] = []
all_nodes = set(seed_entities)
while queue:
current, depth = queue.pop(0)
if depth >= max_depth:
continue
edges = get_edges_fn(current)
for edge in edges:
source = edge.get('source_name', '')
target = edge.get('target_name', '')
edge_key = f'{source}->{target}:{edge.get("relation_type", "")}'
if edge_key not in {f'{e.get("source_name","")}->{e.get("target_name","")}:{e.get("relation_type","")}' for e in collected_edges}:
collected_edges.append(edge)
for neighbor in (source, target):
if neighbor and neighbor != current and neighbor not in visited:
visited.add(neighbor)
all_nodes.add(neighbor)
if len(all_nodes) < max_nodes:
queue.append((neighbor, depth + 1))
if len(all_nodes) >= max_nodes:
break
return {
'nodes': list(all_nodes),
'edges': collected_edges,
}

View File

@ -1,57 +1,79 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any, Callable
from .io_utils import read_text_auto
from .ledger import LedgerStore
from .meeting_digest_agent import MeetingDigestAgent
SubagentProgressCallback = Callable[[dict[str, Any]], None]
CancelCheck = Callable[[], bool]
from meeting_memory.config import config
from meeting_memory.graph_store import graph_store
from meeting_memory.meeting_processor import meeting_processor
class MeetingKnowledgeService:
def __init__(self, data_dir: str | Path = "data", *, workspace: str | Path | None = None) -> None:
self.store = LedgerStore(data_dir)
self.workspace = Path(workspace or Path.cwd()).resolve()
self.digest_agent = MeetingDigestAgent(self.workspace)
ProgressCallback = Callable[[dict[str, Any]], None]
def import_meeting_transcript(
class MeetingMemoryService:
"""Expose the imported meeting-memory pipeline behind app-friendly methods."""
def __init__(self, workspace: str | Path | None = None) -> None:
self.workspace = Path(workspace).resolve() if workspace else None
def store_meeting_memory(
self,
*,
file_path: str,
knowledge_base_id: str | None = None,
team_id: str | None = None,
meeting_date: str | None = None,
progress_callback: SubagentProgressCallback | None = None,
should_cancel: CancelCheck | None = None,
file_path: str | None = None,
content: str | None = None,
force: bool = False,
progress_callback: ProgressCallback | None = None,
use_multistep_extraction: bool = True,
) -> dict[str, Any]:
text = read_text_auto(file_path)
library_id = knowledge_base_id or team_id or "default_team"
digest = self.digest_agent.analyze(
transcript_text=text,
source_path=file_path,
meeting_date=meeting_date or date.today().isoformat(),
knowledge_base_id=library_id,
existing_context=self.store.build_agent_context(library_id),
progress_callback=progress_callback,
should_cancel=should_cancel,
source_path = (file_path or "").strip()
text = (content or "").strip()
if not source_path and not text:
raise ValueError("Either file_path or content is required.")
resolved_path: Path | None = None
if source_path:
resolved_path = Path(source_path).expanduser().resolve()
text = resolved_path.read_text(encoding="utf-8")
def report(step: int, total: int, message: str) -> None:
if callable(progress_callback):
progress_callback(
{
"step": step,
"total_steps": total,
"message": message,
}
)
archive_path = meeting_processor.process_meeting_text(
text,
force=force,
interactive=False,
progress_callback=report,
use_multistep_extraction=use_multistep_extraction,
)
return self.store.import_meeting(library_id, digest)
return {
"success": bool(archive_path),
"stored": bool(archive_path),
"archive_path": archive_path,
"source_file_path": str(resolved_path) if resolved_path else None,
"data_dir": config.storage.data_dir,
"raw_dir": config.storage.raw_dir,
"graph_enabled": graph_store.enabled,
"stats": meeting_processor.stats(),
"message": "Meeting memory stored." if archive_path else "Meeting skipped or processing failed.",
}
def query_knowledge(
self,
*,
query: str,
knowledge_base_id: str | None = None,
team_id: str | None = None,
limit: int = 8,
) -> dict[str, Any]:
library_id = knowledge_base_id or team_id or "default_team"
return self.store.query(library_id, query, limit=limit)
def query_meeting_memory(self, *, query: str, top_k: int = 5) -> dict[str, Any]:
return {
"success": True,
"query": query,
"top_k": top_k,
"answer": meeting_processor.query(query, top_k=top_k),
"graph_enabled": graph_store.enabled,
"stats": meeting_processor.stats(),
}
def list_knowledge_bases(self) -> dict[str, Any]:
bases = self.store.list_team_ids()
return {"knowledge_bases": bases, "count": len(bases)}
def stats(self) -> dict[str, Any]:
return meeting_processor.stats()

View File

@ -0,0 +1,3 @@
from meeting_memory.services.embedding_service import EmbeddingService, embedding_service
__all__ = ["EmbeddingService", "embedding_service"]

View File

@ -0,0 +1,29 @@
from typing import List, Optional
from openai import OpenAI as OpenAIClient
from meeting_memory.config import config
class EmbeddingService:
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
):
self._client = OpenAIClient(
api_key=api_key or config.embedding.api_key or "not-needed",
base_url=api_base or config.embedding.api_base or None,
)
self._model = model or config.embedding.model
def embed_text(self, text: str) -> List[float]:
response = self._client.embeddings.create(
model=self._model,
input=text,
)
return response.data[0].embedding
embedding_service = EmbeddingService()

Some files were not shown because too many files have changed in this diff Show More