union-app-chat-stream 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/PROJECT_OVERVIEW.md +16 -0
  2. package/app/.env +44 -44
  3. package/app/.env.dev +44 -44
  4. package/app/.env.prod.bj11 +44 -44
  5. package/app/.env.prod.sh20 +44 -44
  6. package/app/.env.prod.sz31 +44 -44
  7. package/app/.env.test.bj12 +44 -44
  8. package/app/config/env_config.py +6 -0
  9. package/app/manager/chatstream_manager.py +47 -15
  10. package/app/models/schemas.py +5 -3
  11. package/app/service/chat_service.py +116 -2
  12. package/app/views/view_chatstream.py +62 -5
  13. package/app/wsgi.py +1 -1
  14. package/package.json +3 -1
  15. package/app/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/app/__pycache__/authenticated_user.cpython-312.pyc +0 -0
  17. package/app/__pycache__/extensions.cpython-312.pyc +0 -0
  18. package/app/__pycache__/wsgi.cpython-312.pyc +0 -0
  19. package/app/config/__pycache__/config_loader.cpython-312.pyc +0 -0
  20. package/app/config/__pycache__/env_config.cpython-312.pyc +0 -0
  21. package/app/config/__pycache__/logger_config.cpython-312.pyc +0 -0
  22. package/app/manager/__pycache__/__init__.cpython-312.pyc +0 -0
  23. package/app/manager/__pycache__/chatstream_manager.cpython-312.pyc +0 -0
  24. package/app/manager/__pycache__/prompts.cpython-312.pyc +0 -0
  25. package/app/manager/__pycache__/runtime_manager.cpython-312.pyc +0 -0
  26. package/app/manager/__pycache__/toolcall_manager.cpython-312.pyc +0 -0
  27. package/app/models/__pycache__/schemas.cpython-312.pyc +0 -0
  28. package/app/service/__pycache__/__init__.cpython-312.pyc +0 -0
  29. package/app/service/__pycache__/chat_service.cpython-312.pyc +0 -0
  30. package/app/service/__pycache__/llm_service.cpython-312.pyc +0 -0
  31. package/app/service/__pycache__/rag_service.cpython-312.pyc +0 -0
  32. package/app/service/__pycache__/tool_call_service.cpython-312.pyc +0 -0
  33. package/app/service/__pycache__/union_service.cpython-312.pyc +0 -0
  34. package/app/utils/__pycache__/__init__.cpython-312.pyc +0 -0
  35. package/app/utils/__pycache__/common_utils.cpython-312.pyc +0 -0
  36. package/app/utils/__pycache__/debug_context.cpython-312.pyc +0 -0
  37. package/app/utils/__pycache__/function_utils.cpython-312.pyc +0 -0
  38. package/app/utils/__pycache__/jwt_utils.cpython-312.pyc +0 -0
  39. package/app/views/__pycache__/__init__.cpython-312.pyc +0 -0
  40. package/app/views/__pycache__/view_chatstream.cpython-312.pyc +0 -0
  41. package/app/views/__pycache__/view_healthcheck.cpython-312.pyc +0 -0
  42. package/app/views/__pycache__/view_runtime.cpython-312.pyc +0 -0
@@ -162,6 +162,22 @@ Deployment files are organized under `deploy/`:
162
162
  treat knowledge-base "related functions" as routing hints unless code
163
163
  explicitly promotes them to executable tools.
164
164
 
165
+ ## Streaming Tool-Call Contract
166
+
167
+ - External tool calls may run for minutes or longer because some tools query
168
+ large data platforms such as Hive. Do not treat a long-running tool call as a
169
+ timeout by default.
170
+ - During tool execution, `/chatstream/v1/chat/stream` emits SSE `heartbeat`
171
+ events every `TOOL_CALL_HEARTBEAT_INTERVAL` seconds. The payload is the normal
172
+ chat response JSON with a `heartbeat` object, including `type`, `tool`,
173
+ `elapsedSeconds`, and `message`.
174
+ - Frontends should listen for both `message` and `heartbeat` SSE events.
175
+ Heartbeat events mean the stream is alive and the current tool is still
176
+ running; they are not model content and should not be rendered as answer text.
177
+ - Tool execution failures return a `message` event with `finish_reason="error"`
178
+ and `errorMsg`. If tool-calling reaches `TOOLS_MAX_ROUNDS`, return
179
+ `finish_reason="error"` and `errorMsg="工具调用轮数达到上限(N轮)"`.
180
+
165
181
  ## Validation
166
182
 
167
183
  Use the smallest reliable checks that cover the change:
package/app/.env CHANGED
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=TRUE
19
- LOG_DIR=/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=dev
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=GLM-4.7-Flash
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
package/app/.env.dev CHANGED
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=TRUE
19
- LOG_DIR=/Users/simon/code/union-py-app/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=dev
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=GLM-4.7-Flash
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=FALSE
19
- LOG_DIR=/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=prod.bj11
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=glm-5
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=FALSE
19
- LOG_DIR=/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=prod.sh20
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=glm-5
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=FALSE
19
- LOG_DIR=/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=prod.sz31
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=glm-5
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
@@ -1,63 +1,63 @@
1
1
  # Flask and request authentication
2
- SECRET_KEY=<MASKED_SECRET>
2
+ SECRET_KEY=__MASKED_FOR_NPM__
3
3
 
4
4
  # External API endpoints
5
- GET_USE_INFO_URL=<MASKED_URL>
6
- GET_ORG_INFO_URL=<MASKED_URL>
7
- GET_JIRA_INFO_URL=<MASKED_URL>
8
- GET_BIGDATA_URL=<MASKED_URL>
9
- GET_UNION_BASE_URL=<MASKED_URL>
5
+ GET_USE_INFO_URL=__MASKED_FOR_NPM__
6
+ GET_ORG_INFO_URL=__MASKED_FOR_NPM__
7
+ GET_JIRA_INFO_URL=__MASKED_FOR_NPM__
8
+ GET_BIGDATA_URL=__MASKED_FOR_NPM__
9
+ GET_UNION_BASE_URL=__MASKED_FOR_NPM__
10
10
 
11
11
  # Legacy external API tokens
12
- GET_ORG_INFO_URL_TOKEN=<MASKED_TOKEN>
13
- GET_JIRA_INFO_URL_TOKEN=<MASKED_TOKEN>
12
+ GET_ORG_INFO_URL_TOKEN=__MASKED_FOR_NPM__
13
+ GET_JIRA_INFO_URL_TOKEN=__MASKED_FOR_NPM__
14
14
 
15
15
  # Authorization and logging
16
- PERMISSIONS=
17
- LOG_LEVEL=INFO
18
- CONSOLE_STDOUT=FALSE
19
- LOG_DIR=/data/appLogs
16
+ PERMISSIONS=__MASKED_FOR_NPM__
17
+ LOG_LEVEL=__MASKED_FOR_NPM__
18
+ CONSOLE_STDOUT=__MASKED_FOR_NPM__
19
+ LOG_DIR=__MASKED_FOR_NPM__
20
20
 
21
21
  # Runtime environment and JWT
22
- FLASK_ENV=test.prod.bj12
23
- JWT_SECRET_KEY=<MASKED_SECRET>
24
- JWT_EXPIRATION_SECOND=900
25
- JWT_RENEW_SECOND=700
22
+ FLASK_ENV=__MASKED_FOR_NPM__
23
+ JWT_SECRET_KEY=__MASKED_FOR_NPM__
24
+ JWT_EXPIRATION_SECOND=__MASKED_FOR_NPM__
25
+ JWT_RENEW_SECOND=__MASKED_FOR_NPM__
26
26
 
27
27
  # LLM provider
28
- LLM_URL=<MASKED_URL>
29
- LLM_KEY=<MASKED_KEY>
30
- LLM_MODEL=glm-5
31
- LLM_MAX_TOKENS=4096
32
- LLM_TEMPERATURE=0.7
33
- LLM_TOP_P=0.9
28
+ LLM_URL=__MASKED_FOR_NPM__
29
+ LLM_KEY=__MASKED_FOR_NPM__
30
+ LLM_MODEL=__MASKED_FOR_NPM__
31
+ LLM_MAX_TOKENS=__MASKED_FOR_NPM__
32
+ LLM_TEMPERATURE=__MASKED_FOR_NPM__
33
+ LLM_TOP_P=__MASKED_FOR_NPM__
34
34
 
35
35
  # Chat behavior
36
- SYSTEM_PROMPT=<MASKED_BUSINESS_VALUE>
36
+ SYSTEM_PROMPT=__MASKED_FOR_NPM__
37
37
 
38
38
  # Business filter
39
- FILTER_ENABLED=false
40
- FILTER_ALLOWED_KEYWORDS=<MASKED_BUSINESS_VALUE>
41
- FILTER_REJECTION_MESSAGE=<MASKED_BUSINESS_VALUE>
39
+ FILTER_ENABLED=__MASKED_FOR_NPM__
40
+ FILTER_ALLOWED_KEYWORDS=__MASKED_FOR_NPM__
41
+ FILTER_REJECTION_MESSAGE=__MASKED_FOR_NPM__
42
42
 
43
43
  # Tool and conversation settings
44
- TOOLS_MAX_ROUNDS=5
45
- CONVERSATION_MAX_HISTORY=20
46
- CONVERSATION_TTL=3600
44
+ TOOLS_MAX_ROUNDS=__MASKED_FOR_NPM__
45
+ CONVERSATION_MAX_HISTORY=__MASKED_FOR_NPM__
46
+ CONVERSATION_TTL=__MASKED_FOR_NPM__
47
47
 
48
48
  # RAG settings
49
- RAG_ENABLED=true
50
- RAG_KNOWLEDGE_DIR=knowledge
51
- RAG_PERSIST_DIR=.chroma
52
- RAG_COLLECTION=ops_knowledge
53
- RAG_EMBEDDING_MODEL=embedding-3
54
- RAG_EMBEDDING_MAX_CHARS=6000
55
- RAG_EMBEDDING_BATCH_SIZE=8
56
- RAG_TOP_K=5
57
- RAG_SEMANTIC_CANDIDATE_K=40
58
- RAG_CONTEXT_K=8
59
- RAG_EXACT_CONTEXT_K=3
60
- RAG_EXACT_PER_FILE_CONTEXT_K=1
61
- RAG_PER_FILE_CONTEXT_K=2
62
- RAG_CHUNK_SIZE=1200
63
- RAG_REBUILD_ON_STARTUP=false
49
+ RAG_ENABLED=__MASKED_FOR_NPM__
50
+ RAG_KNOWLEDGE_DIR=__MASKED_FOR_NPM__
51
+ RAG_PERSIST_DIR=__MASKED_FOR_NPM__
52
+ RAG_COLLECTION=__MASKED_FOR_NPM__
53
+ RAG_EMBEDDING_MODEL=__MASKED_FOR_NPM__
54
+ RAG_EMBEDDING_MAX_CHARS=__MASKED_FOR_NPM__
55
+ RAG_EMBEDDING_BATCH_SIZE=__MASKED_FOR_NPM__
56
+ RAG_TOP_K=__MASKED_FOR_NPM__
57
+ RAG_SEMANTIC_CANDIDATE_K=__MASKED_FOR_NPM__
58
+ RAG_CONTEXT_K=__MASKED_FOR_NPM__
59
+ RAG_EXACT_CONTEXT_K=__MASKED_FOR_NPM__
60
+ RAG_EXACT_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
61
+ RAG_PER_FILE_CONTEXT_K=__MASKED_FOR_NPM__
62
+ RAG_CHUNK_SIZE=__MASKED_FOR_NPM__
63
+ RAG_REBUILD_ON_STARTUP=__MASKED_FOR_NPM__
@@ -71,6 +71,12 @@ class Config:
71
71
  )
72
72
 
73
73
  TOOLS_MAX_ROUNDS = _env_int("TOOLS_MAX_ROUNDS", 5)
74
+ TOOL_CALL_HEARTBEAT_INTERVAL = _env_float("TOOL_CALL_HEARTBEAT_INTERVAL", 15.0)
75
+ CHAT_OPENING_QUESTIONS = _env_list("CHAT_OPENING_QUESTIONS", [
76
+ "上周全链路运行质量如何",
77
+ "最近有哪些成员机构交易异常",
78
+ "当前系统运行风险点有哪些",
79
+ ])
74
80
 
75
81
  CONVERSATION_MAX_HISTORY = _env_int("CONVERSATION_MAX_HISTORY", 20)
76
82
  CONVERSATION_TTL = _env_int("CONVERSATION_TTL", 3600)
@@ -14,6 +14,8 @@ class ChatstreamManager:
14
14
  self._chat_service = chat_service
15
15
  self._rag_service = rag_service
16
16
  self._conversations: Dict[str, Dict] = {}
17
+ # ponytail: process-local guard; use shared storage only if workers need cross-process cancellation.
18
+ self._active_streams: Dict[str, Dict] = {}
17
19
  self._max_history = config["CONVERSATION_MAX_HISTORY"]
18
20
  self._ttl = config["CONVERSATION_TTL"]
19
21
  self._lock = threading.Lock()
@@ -62,6 +64,24 @@ class ChatstreamManager:
62
64
  if len(conversation["messages"]) > max_messages:
63
65
  conversation["messages"] = conversation["messages"][-max_messages:]
64
66
 
67
+ def _start_stream(self, jsessionid: str, conversation_id: str) -> threading.Event:
68
+ abort_event = threading.Event()
69
+ with self._lock:
70
+ active = self._active_streams.get(jsessionid)
71
+ if active and active["conversation_id"] != conversation_id:
72
+ active["abort_event"].set()
73
+ self._active_streams[jsessionid] = {
74
+ "conversation_id": conversation_id,
75
+ "abort_event": abort_event,
76
+ }
77
+ return abort_event
78
+
79
+ def _finish_stream(self, jsessionid: str, abort_event: threading.Event):
80
+ with self._lock:
81
+ active = self._active_streams.get(jsessionid)
82
+ if active and active["abort_event"] is abort_event:
83
+ del self._active_streams[jsessionid]
84
+
65
85
  def chat_stream(
66
86
  self,
67
87
  conversation_id: Optional[str],
@@ -69,22 +89,34 @@ class ChatstreamManager:
69
89
  jsessionid: str,
70
90
  ) -> Generator[ChatResponse, None, None]:
71
91
  normalized_conversation_id = self.normalize_conversation_id(conversation_id)
92
+ abort_event = self._start_stream(jsessionid, normalized_conversation_id)
72
93
  history = self._get_history(normalized_conversation_id)
73
94
  answer_parts: List[str] = []
74
- stopped = False
95
+ saved = False
75
96
 
76
- for chunk in self._chat_service.tool_call_stream(
77
- normalized_conversation_id,
78
- question,
79
- tools,
80
- history,
81
- jsessionid,
82
- ):
83
- if chunk.content:
84
- answer_parts.append(chunk.content)
85
- if chunk.finish_reason == "stop":
86
- stopped = True
87
- yield chunk
97
+ try:
98
+ for chunk in self._chat_service.tool_call_stream(
99
+ normalized_conversation_id,
100
+ question,
101
+ tools,
102
+ history,
103
+ jsessionid,
104
+ ):
105
+ if abort_event.is_set():
106
+ yield ChatResponse(
107
+ conversationId=normalized_conversation_id,
108
+ content="当前对话已被新的对话替换,已停止。",
109
+ finish_reason="abort",
110
+ )
111
+ return
112
+ if chunk.content:
113
+ answer_parts.append(chunk.content)
114
+ if chunk.finish_reason and answer_parts and not saved:
115
+ self._append_exchange(normalized_conversation_id, question, "".join(answer_parts))
116
+ saved = True
117
+ yield chunk
88
118
 
89
- if stopped:
90
- self._append_exchange(normalized_conversation_id, question, "".join(answer_parts))
119
+ if answer_parts and not saved:
120
+ self._append_exchange(normalized_conversation_id, question, "".join(answer_parts))
121
+ finally:
122
+ self._finish_stream(jsessionid, abort_event)
@@ -1,11 +1,11 @@
1
- from typing import Literal, Optional
1
+ from typing import Any, Dict, Literal, Optional
2
2
 
3
3
  from pydantic import BaseModel, ConfigDict, Field
4
4
 
5
5
 
6
6
  class ChatRequest(BaseModel):
7
7
  """聊天请求模型"""
8
- model_config = ConfigDict(extra="forbid")
8
+ model_config = ConfigDict(extra="ignore")
9
9
 
10
10
  conversation_id: Optional[str] = Field(
11
11
  default=None,
@@ -24,7 +24,9 @@ class ChatResponse(BaseModel):
24
24
  reasoning_content: Optional[str] = Field(default=None, description="模型推理内容增量")
25
25
  tool_call: Optional[str] = Field(default=None, description="工具调用信息")
26
26
  tool_result: Optional[str] = Field(default=None, description="工具执行结果")
27
- finish_reason: Optional[Literal["stop", "error", "rejected", "done"]] = Field(
27
+ heartbeat: Optional[Dict[str, Any]] = Field(default=None, description="长耗时工具调用心跳")
28
+ error_msg: Optional[str] = Field(default=None, alias="errorMsg", description="错误信息")
29
+ finish_reason: Optional[Literal["stop", "error", "rejected", "done", "abort"]] = Field(
28
30
  default=None,
29
31
  description="结束原因;中间流式增量为空",
30
32
  )
@@ -1,3 +1,7 @@
1
+ import json
2
+ import time
3
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
4
+ from datetime import datetime, timedelta, timezone
1
5
  from pathlib import Path
2
6
  from typing import Dict, Generator, List
3
7
 
@@ -14,6 +18,33 @@ def _preview(text: str, limit: int = 300) -> str:
14
18
  return str(text).replace("\n", " ")[:limit]
15
19
 
16
20
 
21
+ def _public_tool_result(tool_result: str) -> str:
22
+ try:
23
+ payload = json.loads(tool_result)
24
+ except json.JSONDecodeError:
25
+ payload = {}
26
+
27
+ if not isinstance(payload, dict):
28
+ payload = {}
29
+
30
+ public_payload = {
31
+ key: payload[key]
32
+ for key in ("tool_name", "display_name", "arguments", "message")
33
+ if key in payload
34
+ }
35
+ public_payload.setdefault("message", "工具调用完成")
36
+ return json.dumps(public_payload, ensure_ascii=False)
37
+
38
+
39
+ def _time_context() -> str:
40
+ now = datetime.now(timezone.utc).astimezone(timezone(timedelta(hours=8)))
41
+ return (
42
+ "当前基准时间:"
43
+ f"{now:%Y-%m-%d %H:%M:%S} UTC+8。"
44
+ "用户问题中的“当前、近期、上周、昨天、今天”等相对时间,均按该基准时间计算。"
45
+ )
46
+
47
+
17
48
  def _load_prompts() -> Dict[str, str]:
18
49
  path = Path(__file__).resolve().parents[2] / "tools" / "prompts.yaml"
19
50
  if not path.exists():
@@ -55,6 +86,7 @@ class ChatService:
55
86
  self._top_p = config["LLM_TOP_P"]
56
87
  self._system_prompt = config["SYSTEM_PROMPT"]
57
88
  self._tools_max_rounds = config["TOOLS_MAX_ROUNDS"]
89
+ self._tool_heartbeat_interval = float(config.get("TOOL_CALL_HEARTBEAT_INTERVAL", 15.0))
58
90
  self._rag = rag_service
59
91
  self._union_service = union_service
60
92
 
@@ -76,6 +108,7 @@ class ChatService:
76
108
  messages = []
77
109
  if self._system_prompt:
78
110
  messages.append({"role": "system", "content": self._system_prompt})
111
+ messages.append({"role": "system", "content": _time_context()})
79
112
  messages.extend(history)
80
113
  messages.append({"role": "user", "content": user_question})
81
114
  return messages
@@ -118,12 +151,32 @@ class ChatService:
118
151
  return ChatResponse(conversationId=conversation_id, tool_call=tool_call)
119
152
 
120
153
  def tool_result_event(tool_result: str) -> ChatResponse:
121
- return ChatResponse(conversationId=conversation_id, tool_result=tool_result)
154
+ return ChatResponse(conversationId=conversation_id, tool_result=_public_tool_result(tool_result))
155
+
156
+ def heartbeat_event(tool_name: str, elapsed_seconds: float) -> ChatResponse:
157
+ return ChatResponse(
158
+ conversationId=conversation_id,
159
+ heartbeat={
160
+ "type": "tool_call_running",
161
+ "tool": tool_name,
162
+ "elapsedSeconds": round(elapsed_seconds, 3),
163
+ "message": f"工具 {tool_name} 仍在执行,请继续等待。",
164
+ },
165
+ )
166
+
167
+ def error_event(error_msg: str) -> ChatResponse:
168
+ return ChatResponse(
169
+ conversationId=conversation_id,
170
+ content=f"[错误] {error_msg}",
171
+ errorMsg=error_msg,
172
+ finish_reason="error",
173
+ )
122
174
 
123
175
  try:
124
176
  messages = self._build_tool_messages(history, question)
125
177
  max_rounds = self._tools_max_rounds
126
178
  final_answer = ""
179
+ completed_without_tool_call = False
127
180
 
128
181
  logger.info(f"开始模型流式调用。conversation_id={conversation_id} model={self._model} question={_preview(question, 120)}")
129
182
  for round_idx in range(max_rounds):
@@ -168,6 +221,7 @@ class ChatService:
168
221
 
169
222
  if not tool_calls_map:
170
223
  final_answer = current_content
224
+ completed_without_tool_call = True
171
225
  break
172
226
 
173
227
  assistant_tool_calls = [tool_calls_map[i] for i in sorted(tool_calls_map)]
@@ -189,16 +243,33 @@ class ChatService:
189
243
  rag_service=self._rag,
190
244
  jsessionid=jsessionid,
191
245
  )
192
- result = call_function(name, args, tool_context)
246
+ result = yield from self._call_function_with_heartbeats(
247
+ name,
248
+ args,
249
+ tool_context,
250
+ heartbeat_event,
251
+ )
193
252
  logger.info(f"工具调用完成。conversation_id={conversation_id} tool={name} result_preview={_preview(result, 300)}")
194
253
  yield tool_result_event(result)
195
254
 
255
+ tool_error = self._extract_tool_error(result)
256
+ if tool_error:
257
+ logger.error(f"工具调用失败。conversation_id={conversation_id} tool={name} error={tool_error}")
258
+ yield error_event(f"工具调用失败: {tool_error}")
259
+ return
260
+
196
261
  messages.append({
197
262
  "role": "tool",
198
263
  "content": result,
199
264
  "tool_call_id": tc["id"],
200
265
  })
201
266
 
267
+ if not completed_without_tool_call:
268
+ error_msg = f"工具调用轮数达到上限({max_rounds}轮)"
269
+ logger.error(f"对话异常结束。conversation_id={conversation_id} error={error_msg}")
270
+ yield error_event(error_msg)
271
+ return
272
+
202
273
  logger.info(f"对话完成。conversation_id={conversation_id} final_answer_chars={len(final_answer)} final_answer_preview={_preview(final_answer)}")
203
274
  yield ChatResponse(conversationId=conversation_id, finish_reason="stop")
204
275
 
@@ -207,9 +278,52 @@ class ChatService:
207
278
  yield ChatResponse(
208
279
  conversationId=conversation_id,
209
280
  content=f"[错误] 模型调用异常: {str(e)}",
281
+ errorMsg=f"模型调用异常: {str(e)}",
210
282
  finish_reason="error",
211
283
  )
212
284
 
285
+ def _call_function_with_heartbeats(
286
+ self,
287
+ name: str,
288
+ args: str,
289
+ tool_context: ToolContext,
290
+ heartbeat_event,
291
+ ) -> Generator[ChatResponse, None, str]:
292
+ interval = self._tool_heartbeat_interval
293
+ if interval <= 0:
294
+ return call_function(name, args, tool_context)
295
+
296
+ executor = ThreadPoolExecutor(max_workers=1)
297
+ future = executor.submit(call_function, name, args, tool_context)
298
+ started_at = time.monotonic()
299
+ try:
300
+ while True:
301
+ try:
302
+ return future.result(timeout=interval)
303
+ except FutureTimeoutError:
304
+ yield heartbeat_event(name, time.monotonic() - started_at)
305
+ finally:
306
+ executor.shutdown(wait=False, cancel_futures=True)
307
+
308
+ @staticmethod
309
+ def _extract_tool_error(result: str) -> str:
310
+ try:
311
+ payload = json.loads(result)
312
+ except json.JSONDecodeError:
313
+ return ""
314
+ if not isinstance(payload, dict):
315
+ return ""
316
+
317
+ error = payload.get("error")
318
+ if error:
319
+ return str(error)
320
+
321
+ status = payload.get("status")
322
+ if status and status != "success":
323
+ message = payload.get("message") or f"工具返回状态: {status}"
324
+ return str(message)
325
+ return ""
326
+
213
327
  @staticmethod
214
328
  def _merge_tool_call_delta(tool_calls_map: Dict[int, Dict], tc) -> None:
215
329
  """将单个流式 tool_call 增量按 index 合并到累积字典中"""
@@ -1,3 +1,6 @@
1
+ import re
2
+ from itertools import chain
3
+
1
4
  from pydantic import ValidationError
2
5
  from flask import Blueprint, current_app, request, Response, jsonify, stream_with_context, g
3
6
 
@@ -10,6 +13,25 @@ def _sse_event(event: str, data: str) -> str:
10
13
  return f"event: {event}\ndata: {data}\n\n"
11
14
 
12
15
 
16
+ def _error_status(error_msg: str) -> int:
17
+ match = re.search(r"Error code: (\d{3})", error_msg)
18
+ if match:
19
+ status = int(match.group(1))
20
+ if 400 <= status <= 599:
21
+ return status
22
+ return 500
23
+
24
+
25
+ def _error_payload(chunk: ChatResponse) -> dict:
26
+ error_msg = chunk.error_msg or chunk.content or "聊天流异常"
27
+ return {
28
+ "conversationId": chunk.conversation_id,
29
+ "detail": error_msg,
30
+ "errorMsg": error_msg,
31
+ "finish_reason": "error",
32
+ }
33
+
34
+
13
35
  def _chatstream_manager():
14
36
  return current_app.extensions["chatstream_manager"]
15
37
 
@@ -27,6 +49,13 @@ def rag_force_rebuild():
27
49
  return jsonify({"detail": str(exc)}), 500
28
50
 
29
51
 
52
+ @chatstream.post("/chat/opening")
53
+ def chat_opening():
54
+ return jsonify({
55
+ "suggestedQuestions": current_app.config.get("CHAT_OPENING_QUESTIONS", [])
56
+ })
57
+
58
+
30
59
  @chatstream.route("/chat/stream", methods=["OPTIONS", "POST"])
31
60
  def chat_stream_endpoint():
32
61
  if request.method == "OPTIONS":
@@ -37,12 +66,40 @@ def chat_stream_endpoint():
37
66
  except ValidationError as exc:
38
67
  return jsonify({"detail": exc.errors()}), 422
39
68
 
69
+ conversation_id = chat_request.conversation_id
70
+ jsessionid = g.current_user["jsessionid"]
71
+ stream = _chatstream_manager().chat_stream(conversation_id, chat_request.question, jsessionid)
72
+
73
+ try:
74
+ first_chunk = next(stream)
75
+ except StopIteration:
76
+ first_chunk = ChatResponse(conversationId=conversation_id, finish_reason="done")
77
+ except Exception as exc:
78
+ error_msg = f"聊天流异常: {exc}"
79
+ return jsonify({"detail": error_msg, "errorMsg": error_msg, "finish_reason": "error"}), 500
80
+
81
+ if first_chunk.finish_reason == "error":
82
+ return jsonify(_error_payload(first_chunk)), _error_status(first_chunk.error_msg or first_chunk.content or "")
83
+
40
84
  def event_generator():
41
- conversation_id = chat_request.conversation_id
42
- jsessionid = g.current_user["jsessionid"]
43
- for chunk in _chatstream_manager().chat_stream(conversation_id, chat_request.question, jsessionid):
44
- conversation_id = chunk.conversation_id
45
- yield _sse_event("message", chunk.model_dump_json(by_alias=True))
85
+ nonlocal conversation_id
86
+ try:
87
+ for chunk in chain([first_chunk], stream):
88
+ conversation_id = chunk.conversation_id
89
+ event = "heartbeat" if chunk.heartbeat else "message"
90
+ yield _sse_event(event, chunk.model_dump_json(by_alias=True))
91
+ if chunk.finish_reason == "error":
92
+ return
93
+ except Exception as exc:
94
+ error_msg = f"聊天流异常: {exc}"
95
+ error = ChatResponse(
96
+ conversationId=conversation_id,
97
+ content=f"[错误] {error_msg}",
98
+ errorMsg=error_msg,
99
+ finish_reason="error",
100
+ )
101
+ yield _sse_event("message", error.model_dump_json(by_alias=True))
102
+ return
46
103
 
47
104
  done = ChatResponse(conversationId=conversation_id, finish_reason="done")
48
105
  yield _sse_event("done", done.model_dump_json(by_alias=True))
package/app/wsgi.py CHANGED
@@ -34,4 +34,4 @@ except Exception:
34
34
 
35
35
  if __name__ == '__main__':
36
36
  if 'dev' in app.config["FLASK_ENV"]:
37
- app.run(debug=True, host='0.0.0.0', port=8000)
37
+ app.run(debug=True, host='0.0.0.0', port=8083)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "union-app-chat-stream",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Union operations chat stream Flask application package.",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
@@ -8,6 +8,8 @@
8
8
  "deploy",
9
9
  "!deploy/offline-packages",
10
10
  "!deploy/offline-packages/**",
11
+ "!app/**/__pycache__",
12
+ "!app/**/*.pyc",
11
13
  "knowledge",
12
14
  "tools",
13
15
  "PROJECT_OVERVIEW.md",