storepy 2.6.0__tar.gz → 2.7.0__tar.gz
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.
- {storepy-2.6.0 → storepy-2.7.0}/PKG-INFO +67 -1
- {storepy-2.6.0 → storepy-2.7.0}/README.md +66 -0
- {storepy-2.6.0 → storepy-2.7.0}/pyproject.toml +5 -1
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/__init__.py +54 -16
- storepy-2.7.0/src/py_store/ask.py +322 -0
- storepy-2.7.0/src/py_store/ask_knowledge.md +250 -0
- storepy-2.7.0/src/py_store/ddl.py +390 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/feedback.py +5 -0
- storepy-2.7.0/src/py_store/llm.py +141 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/permission.py +48 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/schema.py +36 -1
- storepy-2.7.0/src/py_store/workflow.py +690 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/storepy.egg-info/PKG-INFO +67 -1
- {storepy-2.6.0 → storepy-2.7.0}/src/storepy.egg-info/SOURCES.txt +9 -1
- storepy-2.7.0/tests/test_ask.py +454 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_host_paths.py +48 -45
- storepy-2.7.0/tests/test_migration.py +130 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_profile.py +7 -4
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_py_store.py +24 -24
- storepy-2.7.0/tests/test_rbac_host.py +105 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_require_context.py +7 -4
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_scenario_course_platform.py +7 -6
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_scenario_manager_transaction.py +7 -5
- storepy-2.7.0/tests/test_workflow.py +299 -0
- storepy-2.6.0/src/py_store/ddl.py +0 -191
- {storepy-2.6.0 → storepy-2.7.0}/LICENSE +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/setup.cfg +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/core.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/exec.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/id.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/mutation.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/query.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/crud/write.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/datasource.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/__init__.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/_values.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/mongo.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/mysql.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/postgres.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/executors/sqlite.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/py_store/sync.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_coverage_margin.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_federation_e2e.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_host_contract.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_mongo_executor.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_mongo_session.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_multi_datasource.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_raw_sql_and_ddl.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_session.py +0 -0
- {storepy-2.6.0 → storepy-2.7.0}/tests/test_sql_commit_and_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storepy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
5
|
Author-email: leo <coen_ddt@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -563,6 +563,72 @@ complex reads). Full details, semantics and the explicit-error list:
|
|
|
563
563
|
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
564
564
|
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
565
565
|
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
566
|
+
- **Declarative migration** — `ddl.diff_defs(old, new)` + `ddl.generate_migration(backend, old, new)`: whitelist-only (add table/column/index, type widening), per-dialect SQL, pure functions; destructive changes fail with `MIGRATION_UNSUPPORTED`.
|
|
567
|
+
|
|
568
|
+
## Workflow orchestration (first batch)
|
|
569
|
+
|
|
570
|
+
Express "orchestration of multi-step data operations" as data: a workflow definition (defn) is pure
|
|
571
|
+
JSON isomorphic to a schema defn, and each run is persisted to the built-in schema `__workflowRun`
|
|
572
|
+
(queryable with plain GQL — zero new observability endpoints). Execution generalizes the existing
|
|
573
|
+
mutation step-sequence mechanism: linear steps + per-step `when` guards + fail-fast. The engine
|
|
574
|
+
lives in the host layer (`py_store/workflow.py`), core unchanged; aligned with
|
|
575
|
+
`nodejs-store/src/workflow.js` (byte-identical outputs guarded by parity anchor tests).
|
|
576
|
+
|
|
577
|
+
```python
|
|
578
|
+
from py_store import workflow
|
|
579
|
+
|
|
580
|
+
workflow.register({
|
|
581
|
+
'name': 'placeOrder',
|
|
582
|
+
'run': ['admin', 'ops'], # three-tier whitelists read/write/run (run falls back to write)
|
|
583
|
+
'steps': [
|
|
584
|
+
{'op': 'query', 'as': 'inv',
|
|
585
|
+
'gql': 'Inventory($condition:@c0){_id, stock}',
|
|
586
|
+
'params': {'c0': {'productId': '{{input.productId}}', 'warehouse': '{{input.warehouse}}'}}},
|
|
587
|
+
{'op': 'fail', 'when': {'exists': '{{inv._id}}', 'is': None}, 'message': '库存记录不存在'},
|
|
588
|
+
{'op': 'fail', 'when': {'lt': '{{inv.stock}}', 'than': '{{input.qty}}'}, 'message': '库存不足'},
|
|
589
|
+
{'op': 'mutation', 'model': 'Inventory',
|
|
590
|
+
'data': {'_id': '{{inv._id}}', 'stock': '{{dec:{{inv.stock}},{{input.qty}}}}'}},
|
|
591
|
+
],
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
run = await workflow.run('placeOrder', {'productId': 'p1', 'warehouse': 'w1', 'qty': 30})
|
|
595
|
+
# run['status'] ∈ succeeded | failed | rejected | drySucceeded | dryFailed
|
|
596
|
+
# Uniform contract: business failures never raise; the error lives in run['error']
|
|
597
|
+
# (set only on failure; always null on success — never `||`-masked downstream)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
- **Step whitelist** (three kinds; anything else fails registration with `WORKFLOW_UNSUPPORTED`):
|
|
601
|
+
`query` (result must be unique — >1 row is an explicit error), `mutation` (store.mutation /
|
|
602
|
+
upsert), `fail` (explicit business assertion). Optional `when` guards (exists / is / eq / ne /
|
|
603
|
+
lt / lte / gt / gte) record `skipped` explicitly — never silently skipped.
|
|
604
|
+
- **Placeholders**: `{{input.<path>}}`, `{{<as>.<path>}}` (forward references only),
|
|
605
|
+
`{{dec:<a>,<b>}}`; full-string replacement keeps the value type. No placeholders inside gql
|
|
606
|
+
(bind via params — injection safety); no array-index path segments.
|
|
607
|
+
- **Permissions**: three-tier role whitelists embedded in the defn (same RBAC semantics: admin /
|
|
608
|
+
super_admin bypass, guest denied, internal bypass); runs inherit the caller's Context and every
|
|
609
|
+
step goes through core permission checks — no superuser. `require_context(true)` rejects
|
|
610
|
+
context-less runs (fail-secure wins over dry-run); rejected runs are persisted for audit.
|
|
611
|
+
- **Atomicity**: a single-source run is atomic across steps (outer `run_atomic` wraps the whole
|
|
612
|
+
loop, inner mutations nest into it); multi-source / prescan-failed runs execute sequentially and
|
|
613
|
+
emit feedback events (`workflow_non_atomic` / `workflow_prescan_failed`) — never silent. The run
|
|
614
|
+
record (running → terminal) is committed outside the business transaction so failed runs stay
|
|
615
|
+
queryable after rollback.
|
|
616
|
+
- **dry-run**: `workflow.run(name, input, dry_run=True)` — query steps execute for real (read-only
|
|
617
|
+
safe); mutation / fail are recorded as `wouldRun` (`drySucceeded | dryFailed`).
|
|
618
|
+
- **Run persistence**: `__workflowRun` is bootstrapped on import (idempotent); SQL backends need a
|
|
619
|
+
one-time `ddl.generate(backend, ['__workflowRun'])` (Mongo creates the collection on first write).
|
|
620
|
+
Its `write` whitelist is explicitly empty (GQL tampering with run audit is rejected by R2).
|
|
621
|
+
|
|
622
|
+
### Explicitly not in the first batch (detected → error; boundaries shipped with the same weight as features)
|
|
623
|
+
|
|
624
|
+
| Not supported | Why | Escape hatch |
|
|
625
|
+
|---|---|---|
|
|
626
|
+
| Loops / parallel / sub-workflows / human approval | DAG & wait semantics explode; linear + `when` covers the first batch | orchestrate in host code via the store API |
|
|
627
|
+
| Auto compensation (Saga) / auto retry | Inverse-operation burden; steps have no automatic idempotency | inspect run records and handle explicitly |
|
|
628
|
+
| Per-step host callbacks | Arbitrary code breaks whitelist governance | schema computes (read) / host code (write) |
|
|
629
|
+
| Workflow defn persistence / hot reload | Depends on schema versioning (next on the roadmap) | defn stays code-side JSON + register, like schemas today |
|
|
630
|
+
| Timers / event triggers | Scheduling is a resident-IO concern, orthogonal to pure orchestration | call `run` from the application layer |
|
|
631
|
+
| Placeholders inside gql / array-index paths | Injection surface / per-row iteration semantics | params binding / host-code orchestration |
|
|
566
632
|
|
|
567
633
|
## FAQ
|
|
568
634
|
|
|
@@ -523,6 +523,72 @@ complex reads). Full details, semantics and the explicit-error list:
|
|
|
523
523
|
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
524
524
|
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
525
525
|
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
526
|
+
- **Declarative migration** — `ddl.diff_defs(old, new)` + `ddl.generate_migration(backend, old, new)`: whitelist-only (add table/column/index, type widening), per-dialect SQL, pure functions; destructive changes fail with `MIGRATION_UNSUPPORTED`.
|
|
527
|
+
|
|
528
|
+
## Workflow orchestration (first batch)
|
|
529
|
+
|
|
530
|
+
Express "orchestration of multi-step data operations" as data: a workflow definition (defn) is pure
|
|
531
|
+
JSON isomorphic to a schema defn, and each run is persisted to the built-in schema `__workflowRun`
|
|
532
|
+
(queryable with plain GQL — zero new observability endpoints). Execution generalizes the existing
|
|
533
|
+
mutation step-sequence mechanism: linear steps + per-step `when` guards + fail-fast. The engine
|
|
534
|
+
lives in the host layer (`py_store/workflow.py`), core unchanged; aligned with
|
|
535
|
+
`nodejs-store/src/workflow.js` (byte-identical outputs guarded by parity anchor tests).
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
from py_store import workflow
|
|
539
|
+
|
|
540
|
+
workflow.register({
|
|
541
|
+
'name': 'placeOrder',
|
|
542
|
+
'run': ['admin', 'ops'], # three-tier whitelists read/write/run (run falls back to write)
|
|
543
|
+
'steps': [
|
|
544
|
+
{'op': 'query', 'as': 'inv',
|
|
545
|
+
'gql': 'Inventory($condition:@c0){_id, stock}',
|
|
546
|
+
'params': {'c0': {'productId': '{{input.productId}}', 'warehouse': '{{input.warehouse}}'}}},
|
|
547
|
+
{'op': 'fail', 'when': {'exists': '{{inv._id}}', 'is': None}, 'message': '库存记录不存在'},
|
|
548
|
+
{'op': 'fail', 'when': {'lt': '{{inv.stock}}', 'than': '{{input.qty}}'}, 'message': '库存不足'},
|
|
549
|
+
{'op': 'mutation', 'model': 'Inventory',
|
|
550
|
+
'data': {'_id': '{{inv._id}}', 'stock': '{{dec:{{inv.stock}},{{input.qty}}}}'}},
|
|
551
|
+
],
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
run = await workflow.run('placeOrder', {'productId': 'p1', 'warehouse': 'w1', 'qty': 30})
|
|
555
|
+
# run['status'] ∈ succeeded | failed | rejected | drySucceeded | dryFailed
|
|
556
|
+
# Uniform contract: business failures never raise; the error lives in run['error']
|
|
557
|
+
# (set only on failure; always null on success — never `||`-masked downstream)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- **Step whitelist** (three kinds; anything else fails registration with `WORKFLOW_UNSUPPORTED`):
|
|
561
|
+
`query` (result must be unique — >1 row is an explicit error), `mutation` (store.mutation /
|
|
562
|
+
upsert), `fail` (explicit business assertion). Optional `when` guards (exists / is / eq / ne /
|
|
563
|
+
lt / lte / gt / gte) record `skipped` explicitly — never silently skipped.
|
|
564
|
+
- **Placeholders**: `{{input.<path>}}`, `{{<as>.<path>}}` (forward references only),
|
|
565
|
+
`{{dec:<a>,<b>}}`; full-string replacement keeps the value type. No placeholders inside gql
|
|
566
|
+
(bind via params — injection safety); no array-index path segments.
|
|
567
|
+
- **Permissions**: three-tier role whitelists embedded in the defn (same RBAC semantics: admin /
|
|
568
|
+
super_admin bypass, guest denied, internal bypass); runs inherit the caller's Context and every
|
|
569
|
+
step goes through core permission checks — no superuser. `require_context(true)` rejects
|
|
570
|
+
context-less runs (fail-secure wins over dry-run); rejected runs are persisted for audit.
|
|
571
|
+
- **Atomicity**: a single-source run is atomic across steps (outer `run_atomic` wraps the whole
|
|
572
|
+
loop, inner mutations nest into it); multi-source / prescan-failed runs execute sequentially and
|
|
573
|
+
emit feedback events (`workflow_non_atomic` / `workflow_prescan_failed`) — never silent. The run
|
|
574
|
+
record (running → terminal) is committed outside the business transaction so failed runs stay
|
|
575
|
+
queryable after rollback.
|
|
576
|
+
- **dry-run**: `workflow.run(name, input, dry_run=True)` — query steps execute for real (read-only
|
|
577
|
+
safe); mutation / fail are recorded as `wouldRun` (`drySucceeded | dryFailed`).
|
|
578
|
+
- **Run persistence**: `__workflowRun` is bootstrapped on import (idempotent); SQL backends need a
|
|
579
|
+
one-time `ddl.generate(backend, ['__workflowRun'])` (Mongo creates the collection on first write).
|
|
580
|
+
Its `write` whitelist is explicitly empty (GQL tampering with run audit is rejected by R2).
|
|
581
|
+
|
|
582
|
+
### Explicitly not in the first batch (detected → error; boundaries shipped with the same weight as features)
|
|
583
|
+
|
|
584
|
+
| Not supported | Why | Escape hatch |
|
|
585
|
+
|---|---|---|
|
|
586
|
+
| Loops / parallel / sub-workflows / human approval | DAG & wait semantics explode; linear + `when` covers the first batch | orchestrate in host code via the store API |
|
|
587
|
+
| Auto compensation (Saga) / auto retry | Inverse-operation burden; steps have no automatic idempotency | inspect run records and handle explicitly |
|
|
588
|
+
| Per-step host callbacks | Arbitrary code breaks whitelist governance | schema computes (read) / host code (write) |
|
|
589
|
+
| Workflow defn persistence / hot reload | Depends on schema versioning (next on the roadmap) | defn stays code-side JSON + register, like schemas today |
|
|
590
|
+
| Timers / event triggers | Scheduling is a resident-IO concern, orthogonal to pure orchestration | call `run` from the application layer |
|
|
591
|
+
| Placeholders inside gql / array-index paths | Injection surface / per-row iteration semantics | params binding / host-code orchestration |
|
|
526
592
|
|
|
527
593
|
## FAQ
|
|
528
594
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storepy"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.7.0"
|
|
8
8
|
description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -56,6 +56,10 @@ Issues = "https://github.com/coenddt/py-store/issues"
|
|
|
56
56
|
[tool.setuptools.packages.find]
|
|
57
57
|
where = ["src"]
|
|
58
58
|
|
|
59
|
+
# ask() 的 system prompt 知识源(text-to-query 通用契约裁剪版)随包分发
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
py_store = ["ask_knowledge.md"]
|
|
62
|
+
|
|
59
63
|
[tool.pytest.ini_options]
|
|
60
64
|
testpaths = ["tests"]
|
|
61
65
|
pythonpath = ["src"]
|
|
@@ -20,18 +20,19 @@ py-store — 轻量多后端数据层(Python 版,Rust 单核心架构;支
|
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from collections.abc import Mapping
|
|
23
|
-
from contextlib import contextmanager
|
|
24
23
|
from typing import Any
|
|
25
24
|
|
|
26
25
|
from pymongo.errors import PyMongoError
|
|
27
26
|
|
|
28
27
|
from . import (
|
|
28
|
+
ask,
|
|
29
29
|
crud,
|
|
30
30
|
datasource,
|
|
31
31
|
ddl,
|
|
32
32
|
feedback,
|
|
33
33
|
permission,
|
|
34
34
|
schema,
|
|
35
|
+
workflow,
|
|
35
36
|
)
|
|
36
37
|
from . import (
|
|
37
38
|
executors as executors,
|
|
@@ -39,10 +40,16 @@ from . import (
|
|
|
39
40
|
from . import (
|
|
40
41
|
introspect as introspect,
|
|
41
42
|
)
|
|
43
|
+
from .ask import AskExhausted as AskExhausted
|
|
44
|
+
from .ask import AskResult as AskResult
|
|
42
45
|
from .datasource import NativeCommandError as NativeCommandError
|
|
43
46
|
from .datasource import NonAtomicWriteError as NonAtomicWriteError
|
|
44
47
|
from .datasource import RawSqlError as RawSqlError
|
|
45
48
|
from .datasource import Session as Session
|
|
49
|
+
from .llm import get_llm as get_llm
|
|
50
|
+
from .llm import make_openai_compat as make_openai_compat
|
|
51
|
+
from .llm import register_llm as register_llm
|
|
52
|
+
from .schema import text2query
|
|
46
53
|
from .sync import sync_schema
|
|
47
54
|
|
|
48
55
|
|
|
@@ -52,21 +59,6 @@ def _build_pipeline(gql, params=None):
|
|
|
52
59
|
gql, params if params is not None else {}, permission.get_context())
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
@contextmanager
|
|
56
|
-
def text2query():
|
|
57
|
-
"""以 text2query 档执行(功能收缩 + 硬限制),退出恢复原档位。
|
|
58
|
-
|
|
59
|
-
AI 问数链路入口;与 ``permission.scoped_roles`` 同构(token-set/reset,嵌套安全)。
|
|
60
|
-
进入档位即等效强制携带用户上下文(core `ensure_profile_ctx`,见执行文档 §4.2)。
|
|
61
|
-
"""
|
|
62
|
-
prev = schema.get_profile()
|
|
63
|
-
schema.set_profile('text2query')
|
|
64
|
-
try:
|
|
65
|
-
yield
|
|
66
|
-
finally:
|
|
67
|
-
schema.set_profile(prev)
|
|
68
|
-
|
|
69
|
-
|
|
70
62
|
class Store:
|
|
71
63
|
"""以属性方式访问各 API(**全显式类方法,无动态查找**)
|
|
72
64
|
|
|
@@ -203,6 +195,35 @@ class Store:
|
|
|
203
195
|
"""从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6)"""
|
|
204
196
|
return ddl.generate(backend, names)
|
|
205
197
|
|
|
198
|
+
# ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.py 与设计文档)──
|
|
199
|
+
def registerWorkflow(self, defn: dict) -> dict:
|
|
200
|
+
"""注册工作流定义(注册即静态校验,白名单外显式 Err 含 WORKFLOW_UNSUPPORTED)"""
|
|
201
|
+
return workflow.register(defn)
|
|
202
|
+
|
|
203
|
+
def workflows(self, ctx: dict | None = None) -> list[str]:
|
|
204
|
+
"""全部可见工作流名(read 白名单过滤)"""
|
|
205
|
+
return workflow.list(ctx)
|
|
206
|
+
|
|
207
|
+
def getWorkflow(self, name: str, ctx: dict | None = None) -> dict:
|
|
208
|
+
"""按名取工作流定义(read 白名单过滤;不可见与不存在同形——防枚举)"""
|
|
209
|
+
return workflow.get(name, ctx)
|
|
210
|
+
|
|
211
|
+
async def runWorkflow(self, name: str, input: dict | None = None, *,
|
|
212
|
+
dry_run: bool = False,
|
|
213
|
+
route_override: dict | None = None) -> dict[str, Any]:
|
|
214
|
+
"""触发工作流 → 完整 run 文档(终态 failed/rejected 不抛错,以 run.status + error 表达)"""
|
|
215
|
+
return await workflow.run(name, input, dry_run=dry_run, route_override=route_override)
|
|
216
|
+
|
|
217
|
+
# ── AI 问数(L1,只读):自然语言 → LLM 翻译 → text2query 沙箱执行 → 结构化回喂 ──
|
|
218
|
+
# 护栏(档位/ctx/route_override)全部服务端硬编码于 ask.py,零暴露进 LLM 消息面(D5)
|
|
219
|
+
# (先于 ask 赋值取 describe_for_ai:赋值后类体命名空间的 ask 不再是模块)
|
|
220
|
+
describeForAi = staticmethod(ask.describe_for_ai)
|
|
221
|
+
describe_for_ai = describeForAi
|
|
222
|
+
ask = staticmethod(ask.ask)
|
|
223
|
+
# 问数结果/耗尽错误(实例可被 store.AskExhausted 捕获;对齐 PermissionError 先例)
|
|
224
|
+
AskResult = AskResult
|
|
225
|
+
AskExhausted = AskExhausted
|
|
226
|
+
|
|
206
227
|
# ── 驼峰别名(与上方同名蛇形方法为**同一实现**,仅命名差异)──
|
|
207
228
|
queryOne = query_one
|
|
208
229
|
queryWithCount = query_with_count
|
|
@@ -214,6 +235,10 @@ class Store:
|
|
|
214
235
|
executeRaw = execute_raw
|
|
215
236
|
executeNative = execute_native
|
|
216
237
|
generateDdl = generate_ddl
|
|
238
|
+
# 工作流编排(蛇形别名与上方驼峰同实现)
|
|
239
|
+
register_workflow = registerWorkflow
|
|
240
|
+
run_workflow = runWorkflow
|
|
241
|
+
get_workflow = getWorkflow
|
|
217
242
|
|
|
218
243
|
# ── 其余 API 显式绑定(staticmethod:避免实例化后 self 注入)──
|
|
219
244
|
# Schema 管理
|
|
@@ -243,6 +268,19 @@ class Store:
|
|
|
243
268
|
set_require_context = staticmethod(schema.set_require_context)
|
|
244
269
|
requireContext = staticmethod(schema.require_context)
|
|
245
270
|
require_context = staticmethod(schema.require_context)
|
|
271
|
+
# RBAC 动态策略(判决唯一在 core;本层仅透传配置与查询面)
|
|
272
|
+
setRbac = staticmethod(permission.set_rbac)
|
|
273
|
+
set_rbac = staticmethod(permission.set_rbac)
|
|
274
|
+
rbacEnabled = staticmethod(permission.rbac_enabled)
|
|
275
|
+
rbac_enabled = staticmethod(permission.rbac_enabled)
|
|
276
|
+
rbacCan = staticmethod(permission.rbac_can)
|
|
277
|
+
rbac_can = staticmethod(permission.rbac_can)
|
|
278
|
+
rbacReadableFields = staticmethod(permission.rbac_readable_fields)
|
|
279
|
+
rbac_readable_fields = staticmethod(permission.rbac_readable_fields)
|
|
280
|
+
rbacWritableFields = staticmethod(permission.rbac_writable_fields)
|
|
281
|
+
rbac_writable_fields = staticmethod(permission.rbac_writable_fields)
|
|
282
|
+
rbacRowCondition = staticmethod(permission.rbac_row_condition)
|
|
283
|
+
rbac_row_condition = staticmethod(permission.rbac_row_condition)
|
|
246
284
|
# 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩
|
|
247
285
|
# 进入档即等效强制 ctx;未知档由 core 抛 ValueError 上抛(禁静默回落默认档)
|
|
248
286
|
setProfile = staticmethod(schema.set_profile)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""AI 问数(L1)—— ``ask()`` 唯一入口 + ``describe_for_ai`` schema 摘要生成器
|
|
2
|
+
|
|
3
|
+
《AI能力接入设计-L1问数档.md》§4 的宿主侧两个新组件,把既有能力接线:
|
|
4
|
+
|
|
5
|
+
用户问题 → ① describe_for_ai(ctx) 权限过滤摘要
|
|
6
|
+
→ ② LLM(注入式客户端)翻译为 {"gql","params"}
|
|
7
|
+
→ ③ with text2query(): 规划期校验(core 判决:语法/档位/权限/硬限)
|
|
8
|
+
→ ④ crud.query 执行(只读)
|
|
9
|
+
→ ⑤ 失败结构化回喂 LLM 重试(≤ max_retries 次),耗尽抛 AskExhausted
|
|
10
|
+
|
|
11
|
+
护栏面(D5,服务端硬编码,LLM 零可触):
|
|
12
|
+
- 档位 = text2query:本模块硬编码 ``with text2query():`` 包裹全部执行;
|
|
13
|
+
- 用户上下文 = ctx:服务端注入参数,经 ``permission.scoped_context`` 进执行面,
|
|
14
|
+
绝不进入任何 LLM 消息;core 档位门禁强制无 ctx 即拒(fail-secure,A4);
|
|
15
|
+
- route_override = None:硬编码(CWE-639;Host 兜底 ``_guard_route_override`` 双保险);
|
|
16
|
+
- 行数/深度/联邦硬限:core 常量(T2Q_MAX_ROWS=1000 / T2Q_MAX_DEPTH=3 / ...,A5);
|
|
17
|
+
- 只读:编排面仅 ``crud.query``(mutation/remove 属 L2,禁入)。
|
|
18
|
+
|
|
19
|
+
LLM 输出永远当不可信输入:唯一产出形状 ``{"gql","params"}`` 单 JSON 对象(D3),
|
|
20
|
+
严格 JSON 解析、禁正则容错提取;幻觉最坏后果是「规划失败 + 结构化错误回喂」,
|
|
21
|
+
不可能变成不受控命令。一切失败结构化显式暴露(no-error-masking:是错就是错,
|
|
22
|
+
禁降级、禁返回空结果——「问数失败」就是失败,交上层裁决,D4)。
|
|
23
|
+
|
|
24
|
+
并发限制(如实声明):``text2query`` 档位(core 单例)与反馈 sink 为进程级全局,
|
|
25
|
+
同一进程内并发调用 ``ask()`` 会互相串扰,宿主需串行化(或每任务独享进程/事件循环)。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib.resources
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from . import crud, feedback, permission, schema
|
|
35
|
+
from .core import core
|
|
36
|
+
from .llm import get_llm as _get_llm
|
|
37
|
+
from .schema import text2query
|
|
38
|
+
|
|
39
|
+
__all__ = ['AskExhausted', 'AskResult', 'ask', 'describe_for_ai']
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── 轨迹载体 ──────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AskResult:
|
|
47
|
+
"""问数成功结果:查询数据 + 全部尝试轨迹 + 反馈事件
|
|
48
|
+
|
|
49
|
+
- ``data``:查询结果数组(crud.query 原样返回);
|
|
50
|
+
- ``attempts``:每次尝试一条(``llm_raw`` / ``gql`` / ``params`` / ``rows``),
|
|
51
|
+
最后一轮为成功轮——**不含任何 error 键**(成功态无错误字段,no-error-masking 正向断言);
|
|
52
|
+
- ``events``:执行期接管到的反馈事件(拦截/降级告警;无拦截时为空列表)。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
data: list
|
|
56
|
+
attempts: list = field(default_factory=list)
|
|
57
|
+
events: list = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AskExhausted(Exception):
|
|
61
|
+
"""重试耗尽:max_retries 次回喂重试后仍失败(D4:显式失败,不降级、不返回空结果)
|
|
62
|
+
|
|
63
|
+
``attempts`` / ``events`` 携带全部尝试轨迹;消息内嵌最后一轮结构化错误,
|
|
64
|
+
上游不读属性也能看到失败原因。
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, attempts, events):
|
|
68
|
+
last_error = (attempts[-1].get('error') if attempts else None) or {'code': 'unknown'}
|
|
69
|
+
super().__init__(
|
|
70
|
+
f'ask() 重试耗尽(共 {len(attempts)} 次尝试全部失败),不降级、不返回空结果;'
|
|
71
|
+
f'最后一轮错误: {json.dumps(last_error, ensure_ascii=False)};'
|
|
72
|
+
'完整轨迹见 .attempts / .events'
|
|
73
|
+
)
|
|
74
|
+
self.attempts = attempts
|
|
75
|
+
self.events = events
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─── 组件 A:schema 摘要生成器 ─────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# 计算列收窄告警去重(同 (model, compute) 只告警一次,对齐 schema.list 的 _dup_signatures 先例)
|
|
81
|
+
_COMPUTE_SKIP_SIGS: set = set()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _field_type(fdef):
|
|
85
|
+
"""字段类型字符串(str 形态原样;dict 形态取 type;其余显式 None,不伪造)"""
|
|
86
|
+
if isinstance(fdef, str):
|
|
87
|
+
return fdef
|
|
88
|
+
if isinstance(fdef, dict):
|
|
89
|
+
return fdef.get('type')
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _emit_compute_skipped(model, compute):
|
|
94
|
+
sig = (model, compute)
|
|
95
|
+
if sig in _COMPUTE_SKIP_SIGS:
|
|
96
|
+
return
|
|
97
|
+
_COMPUTE_SKIP_SIGS.add(sig)
|
|
98
|
+
feedback.emit({
|
|
99
|
+
'type': 'ask_summary_compute_skipped',
|
|
100
|
+
'code': 'askSummaryComputeSkipped',
|
|
101
|
+
'layer': 'host',
|
|
102
|
+
'message': (f'AI 摘要收窄:模型 {model} 的计算列 {compute} 配置了 read 白名单,'
|
|
103
|
+
'core-py 绑定未导出 readable_computes 判决,为不越权暴露已从摘要排除'),
|
|
104
|
+
'hint': ('去掉该计算列的 read 配置可进摘要;或在 core-py 导出 readable_computes '
|
|
105
|
+
'后接入 describe_for_ai(执行面权限判决始终在 core,此处仅摘要暴露面收窄)'),
|
|
106
|
+
'model': model,
|
|
107
|
+
'compute': compute,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_for_ai(ctx=None) -> list[dict]:
|
|
112
|
+
"""输出 LLM 可读的 schema 摘要(紧凑 JSON 数组,每模型一条)。
|
|
113
|
+
|
|
114
|
+
过滤规则(顺序固定,设计文档 §4.1):
|
|
115
|
+
1. 排除归档表(名称以 ``Deleted`` 结尾,对齐 store-api 派生路由先例);
|
|
116
|
+
2. 模型级按 core ``can_read``;字段/关系按 core 角色可读集(``readable_fields`` /
|
|
117
|
+
``readable_relations``,列级白名单);计算列按声明 read 白名单**保守收窄**——
|
|
118
|
+
core-py 未导出 ``readable_computes`` 判决,配了 read 的计算列不进摘要并 emit
|
|
119
|
+
告警(宁缺勿泄;执行面判决始终在 core,收窄只影响摘要暴露面);
|
|
120
|
+
无 ctx → 仅暴露模型名与字段名,不暴露类型细节(防探针);
|
|
121
|
+
3. 不输出 indexes / datasource / namespace(运维细节不进 prompt)。
|
|
122
|
+
"""
|
|
123
|
+
summaries = []
|
|
124
|
+
for name in schema.list():
|
|
125
|
+
if name.endswith('Deleted'):
|
|
126
|
+
continue
|
|
127
|
+
mirror = schema.get(name)
|
|
128
|
+
if ctx is None:
|
|
129
|
+
summaries.append({'name': name, 'fields': sorted(mirror['fields'])})
|
|
130
|
+
continue
|
|
131
|
+
if not core.can_read(name, ctx):
|
|
132
|
+
continue
|
|
133
|
+
readable = set(core.readable_fields(name, ctx))
|
|
134
|
+
fields = {fname: _field_type(fdef)
|
|
135
|
+
for fname, fdef in mirror['fields'].items() if fname in readable}
|
|
136
|
+
for fname in sorted(readable - set(mirror['fields'])):
|
|
137
|
+
# core 自动补的时间戳字段(createdAt/updatedAt)不在 Host 镜像——类型显式留白
|
|
138
|
+
fields[fname] = None
|
|
139
|
+
relations = {}
|
|
140
|
+
readable_rels = set(core.readable_relations(name, ctx))
|
|
141
|
+
for rname, rdef in mirror['relations'].items():
|
|
142
|
+
if rname in readable_rels:
|
|
143
|
+
relations[rname] = {'model': rdef.get('model'), 'type': rdef.get('type')}
|
|
144
|
+
computes = {}
|
|
145
|
+
for cname, cdef in mirror['computes'].items():
|
|
146
|
+
if cdef.get('read') is not None:
|
|
147
|
+
_emit_compute_skipped(name, cname)
|
|
148
|
+
continue
|
|
149
|
+
entry = {}
|
|
150
|
+
if cdef.get('agg') is not None:
|
|
151
|
+
entry['agg'] = cdef['agg']
|
|
152
|
+
if cdef.get('type') is not None:
|
|
153
|
+
entry['type'] = cdef['type']
|
|
154
|
+
computes[cname] = entry
|
|
155
|
+
summaries.append({
|
|
156
|
+
'name': name, 'fields': fields,
|
|
157
|
+
'relations': relations, 'computes': computes,
|
|
158
|
+
})
|
|
159
|
+
return summaries
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ─── 组件 B:ask() 编排器 ──────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
_KNOWLEDGE_CACHE: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _BadLlmOutput(Exception):
|
|
168
|
+
"""LLM 输出未通过严格解析(携带结构化 detail 供回喂)——内部控制流,不外穿"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, detail):
|
|
171
|
+
super().__init__(detail.get('detail', 'badLlmOutput'))
|
|
172
|
+
self.detail = detail
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _load_knowledge(knowledge):
|
|
176
|
+
"""知识文本:传参优先(测试/自定义);缺省读包内 ask_knowledge.md(text-to-query 裁剪版)"""
|
|
177
|
+
global _KNOWLEDGE_CACHE
|
|
178
|
+
if knowledge is not None:
|
|
179
|
+
return knowledge
|
|
180
|
+
if _KNOWLEDGE_CACHE is None:
|
|
181
|
+
_KNOWLEDGE_CACHE = (
|
|
182
|
+
importlib.resources.files(__package__) / 'ask_knowledge.md'
|
|
183
|
+
).read_text(encoding='utf-8')
|
|
184
|
+
return _KNOWLEDGE_CACHE
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _build_system_prompt(summary, knowledge):
|
|
188
|
+
"""system prompt = 翻译知识 + 摘要 + 输出契约(D3;含 "json" 字样满足 json_mode 预检)"""
|
|
189
|
+
return (
|
|
190
|
+
f'{knowledge}\n\n'
|
|
191
|
+
'## 可用模型摘要(当前用户可见;字段/关系/计算列已按权限过滤)\n'
|
|
192
|
+
f'{json.dumps(summary, ensure_ascii=False)}\n\n'
|
|
193
|
+
'## 输出契约(唯一产出形状)\n'
|
|
194
|
+
'只输出一个 json 对象:{"gql": "<GQL 查询串>", "params": {<参数对象>}}。\n'
|
|
195
|
+
'- 条件值一律参数化:GQL 内用 @key 引用,真实值放 params(键 = 去掉 @ 的引用名);\n'
|
|
196
|
+
' 禁止内联值进 GQL 串。\n'
|
|
197
|
+
'- 禁止输出解释文字、markdown 代码围栏、或除 gql/params 外的任何键。\n'
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_llm_output(raw):
|
|
202
|
+
"""严格 JSON 解析 + 形状校验(D3);失败抛 ``_BadLlmOutput``(禁正则容错提取)"""
|
|
203
|
+
def fail(detail):
|
|
204
|
+
return _BadLlmOutput({
|
|
205
|
+
'code': 'badLlmOutput',
|
|
206
|
+
'message': 'LLM 输出不是合法的 {"gql","params"} 单 JSON 对象',
|
|
207
|
+
'detail': detail,
|
|
208
|
+
'raw': (raw or '')[:500],
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
if not isinstance(raw, str):
|
|
212
|
+
raise fail(f'输出不是字符串: {type(raw).__name__}')
|
|
213
|
+
try:
|
|
214
|
+
parsed = json.loads(raw)
|
|
215
|
+
except ValueError as e:
|
|
216
|
+
raise fail(f'JSON 解析失败: {e}') from e
|
|
217
|
+
if not isinstance(parsed, dict):
|
|
218
|
+
raise fail(f'顶层不是 JSON 对象: {type(parsed).__name__}')
|
|
219
|
+
if set(parsed) != {'gql', 'params'}:
|
|
220
|
+
raise fail(f'键集合必须恰为 gql/params,实际: {sorted(parsed)}')
|
|
221
|
+
gql, params = parsed['gql'], parsed['params']
|
|
222
|
+
if not isinstance(gql, str) or not gql.strip():
|
|
223
|
+
raise fail('gql 必须为非空字符串')
|
|
224
|
+
if not isinstance(params, dict):
|
|
225
|
+
raise fail('params 必须为 JSON 对象')
|
|
226
|
+
return gql, params
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _error_from_exception(e, round_events):
|
|
230
|
+
"""执行失败 → 回喂错误对象:core/Host 拦截事件**原样透传**(禁改写禁摘要);
|
|
231
|
+
无事件兜底构造时只给确证字段(feature/layer 留白不伪造)"""
|
|
232
|
+
for ev in round_events:
|
|
233
|
+
if ev.get('type') == 'profile_blocked':
|
|
234
|
+
return dict(ev)
|
|
235
|
+
if isinstance(e, permission.PermissionError):
|
|
236
|
+
return {'code': 'permissionDenied', 'message': str(e)}
|
|
237
|
+
if isinstance(e, crud.ProfileViolation):
|
|
238
|
+
return {'code': 'profileBlocked', 'message': str(e)}
|
|
239
|
+
return {'code': 'planError', 'message': str(e)}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def ask(question: str, *, llm, ctx: dict, max_retries: int = 3,
|
|
243
|
+
knowledge: str | None = None) -> AskResult:
|
|
244
|
+
"""AI 问数唯一入口(L1 只读):自然语言 → LLM 翻译 → 受控沙箱执行 → 结构化回喂。
|
|
245
|
+
|
|
246
|
+
参数:
|
|
247
|
+
question : 自然语言问题(非空字符串)。
|
|
248
|
+
llm : 注册名(str,经 ``llm.get_llm``)或现成客户端
|
|
249
|
+
(协议 ``async def(messages) -> str``,D2/D6 注入式,py-store 零 SDK)。
|
|
250
|
+
ctx : 服务端构造的用户上下文(``{'userId': ..., 'roles': [...]}``);
|
|
251
|
+
None 直接拒绝(fail-secure);LLM 永远碰不到本参数(D5)。
|
|
252
|
+
max_retries : 失败后的最大**重试**次数(总尝试 ≤ 1 + max_retries);耗尽抛 ``AskExhausted``。
|
|
253
|
+
knowledge : 覆盖 system prompt 知识文本(缺省用包内 ask_knowledge.md)。
|
|
254
|
+
|
|
255
|
+
返回 ``AskResult(data, attempts, events)``;LLM 客户端自身的异常(网络/HTTP/空 content,
|
|
256
|
+
``RuntimeError(dict)`` 结构化)**原样穿透**——回喂循环只裁决「翻译质量/查询合法性」,
|
|
257
|
+
链路故障显式失败不重试。
|
|
258
|
+
|
|
259
|
+
轨迹契约:成功轮 attempt 不含 error 键;每次失败以 user 消息追加
|
|
260
|
+
``{"error": {...}}``(core/Host 结构化错误原样透传,§4.3)。
|
|
261
|
+
"""
|
|
262
|
+
if not isinstance(question, str) or not question.strip():
|
|
263
|
+
raise ValueError('ask(question) 需要非空自然语言问题字符串')
|
|
264
|
+
if ctx is None:
|
|
265
|
+
# fail-secure:core text2query 档强制 ctx(ensure_profile_ctx)——入口先拒,
|
|
266
|
+
# 免一次注定失败的 LLM 调用;空 dict 等其余边界交 core/权限层如实判决
|
|
267
|
+
raise ValueError(
|
|
268
|
+
'ask() 需要服务端构造的用户上下文 ctx(如 {"userId": ..., "roles": [...]});'
|
|
269
|
+
'ctx 属受信参数,LLM 永远碰不到(D5)')
|
|
270
|
+
if not isinstance(max_retries, int) or max_retries < 0:
|
|
271
|
+
raise ValueError(f'max_retries 必须为非负整数,实际: {max_retries!r}')
|
|
272
|
+
client = llm if callable(llm) else _get_llm(llm)
|
|
273
|
+
system = _build_system_prompt(describe_for_ai(ctx), _load_knowledge(knowledge))
|
|
274
|
+
|
|
275
|
+
messages = [
|
|
276
|
+
{'role': 'system', 'content': system},
|
|
277
|
+
{'role': 'user', 'content': question},
|
|
278
|
+
]
|
|
279
|
+
attempts: list[dict] = []
|
|
280
|
+
events: list[dict] = []
|
|
281
|
+
round_events: list[dict] = []
|
|
282
|
+
|
|
283
|
+
def _collect(event):
|
|
284
|
+
events.append(event)
|
|
285
|
+
round_events.append(event)
|
|
286
|
+
|
|
287
|
+
prev_sink = feedback.get_sink()
|
|
288
|
+
feedback.set_sink(_collect)
|
|
289
|
+
try:
|
|
290
|
+
with text2query(), permission.scoped_context(ctx):
|
|
291
|
+
for _ in range(1 + max_retries):
|
|
292
|
+
round_events.clear()
|
|
293
|
+
# LLM 客户端异常(llmNetworkError/llmHttpError/llmEmptyContent)原样穿透
|
|
294
|
+
raw = await client(messages)
|
|
295
|
+
attempt = {'llm_raw': raw}
|
|
296
|
+
try:
|
|
297
|
+
gql, params = _parse_llm_output(raw)
|
|
298
|
+
except _BadLlmOutput as e:
|
|
299
|
+
attempt['error'] = e.detail
|
|
300
|
+
attempts.append(attempt)
|
|
301
|
+
messages.append({'role': 'assistant', 'content': raw})
|
|
302
|
+
messages.append({'role': 'user', 'content': json.dumps(
|
|
303
|
+
{'error': e.detail}, ensure_ascii=False)})
|
|
304
|
+
continue
|
|
305
|
+
attempt['gql'] = gql
|
|
306
|
+
attempt['params'] = params
|
|
307
|
+
try:
|
|
308
|
+
# route_override 硬编码 None(受信参数,禁 AI 侧指定,D5/CWE-639)
|
|
309
|
+
data = await crud.query(gql, params, None)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
attempt['error'] = _error_from_exception(e, round_events)
|
|
312
|
+
attempts.append(attempt)
|
|
313
|
+
messages.append({'role': 'assistant', 'content': raw})
|
|
314
|
+
messages.append({'role': 'user', 'content': json.dumps(
|
|
315
|
+
{'error': attempt['error']}, ensure_ascii=False)})
|
|
316
|
+
continue
|
|
317
|
+
attempt['rows'] = len(data)
|
|
318
|
+
attempts.append(attempt)
|
|
319
|
+
return AskResult(data=data, attempts=attempts, events=events)
|
|
320
|
+
finally:
|
|
321
|
+
feedback.set_sink(prev_sink)
|
|
322
|
+
raise AskExhausted(attempts, events)
|