@my-life-buddies/cli 0.14.1 → 0.15.0
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.
- package/README.md +63 -15
- package/dist/bin/buddy.js +28 -9
- package/dist/bin/buddy.js.map +2 -2
- package/dist/bin/core.js +1293 -603
- package/dist/bin/core.js.map +4 -4
- package/dist/bin/preview.js +43 -8
- package/dist/bin/preview.js.map +2 -2
- package/dist/web/app.js +11 -6
- package/dist/web/widgets.css +59 -28
- package/dist/web/widgets.js +230 -76
- package/package.json +3 -3
- package/resources/README.md +6 -50
- package/resources/buddy-creator/INSTALL.md +0 -25
- package/resources/buddy-creator/PATCHES.md +0 -53
- package/resources/buddy-creator/SKILL.md +0 -76
- package/resources/buddy-creator/THIRD_PARTY_NOTICES.md +0 -154
- package/resources/buddy-creator/agents/openai.yaml +0 -4
- package/resources/buddy-creator/assets/licenses/bail.txt +0 -26
- package/resources/buddy-creator/assets/licenses/ccount.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities-html4.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities-legacy.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-reference-invalid.txt +0 -26
- package/resources/buddy-creator/assets/licenses/comma-separated-tokens.txt +0 -26
- package/resources/buddy-creator/assets/licenses/debug.txt +0 -24
- package/resources/buddy-creator/assets/licenses/decode-named-character-reference.txt +0 -26
- package/resources/buddy-creator/assets/licenses/dequal.txt +0 -25
- package/resources/buddy-creator/assets/licenses/devlop.txt +0 -26
- package/resources/buddy-creator/assets/licenses/escape-string-regexp.txt +0 -13
- package/resources/buddy-creator/assets/licenses/estree-util-is-identifier-name.txt +0 -26
- package/resources/buddy-creator/assets/licenses/extend.txt +0 -27
- package/resources/buddy-creator/assets/licenses/hast-util-to-jsx-runtime.txt +0 -26
- package/resources/buddy-creator/assets/licenses/hast-util-whitespace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/html-url-attributes.txt +0 -25
- package/resources/buddy-creator/assets/licenses/inline-style-parser.txt +0 -13
- package/resources/buddy-creator/assets/licenses/is-alphabetical.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-alphanumerical.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-decimal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-hexadecimal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-plain-obj.txt +0 -13
- package/resources/buddy-creator/assets/licenses/longest-streak.txt +0 -26
- package/resources/buddy-creator/assets/licenses/markdown-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-find-and-replace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-from-markdown.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-autolink-literal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-footnote.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-strikethrough.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-task-list-item.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-expression.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-jsx.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdxjs-esm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-phrasing.txt +0 -27
- package/resources/buddy-creator/assets/licenses/mdast-util-to-hast.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-to-markdown.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-to-string.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-core-commonmark.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-autolink-literal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-footnote.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-strikethrough.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-tagfilter.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-task-list-item.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-destination.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-label.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-space.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-title.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-whitespace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-character.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-chunked.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-classify-character.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-combine-extensions.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-numeric-character-reference.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-string.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-encode.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-html-tag-name.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-normalize-identifier.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-resolve-all.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-sanitize-uri.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-subtokenize.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-symbol.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-types.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark.txt +0 -26
- package/resources/buddy-creator/assets/licenses/ms.txt +0 -25
- package/resources/buddy-creator/assets/licenses/parse-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/phosphor-icons--react.txt +0 -25
- package/resources/buddy-creator/assets/licenses/property-information.txt +0 -26
- package/resources/buddy-creator/assets/licenses/react-dom.txt +0 -25
- package/resources/buddy-creator/assets/licenses/react-markdown.txt +0 -25
- package/resources/buddy-creator/assets/licenses/react.txt +0 -25
- package/resources/buddy-creator/assets/licenses/remark-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/remark-parse.txt +0 -25
- package/resources/buddy-creator/assets/licenses/remark-rehype.txt +0 -26
- package/resources/buddy-creator/assets/licenses/remark-stringify.txt +0 -25
- package/resources/buddy-creator/assets/licenses/scheduler.txt +0 -25
- package/resources/buddy-creator/assets/licenses/space-separated-tokens.txt +0 -26
- package/resources/buddy-creator/assets/licenses/stringify-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/style-to-js.txt +0 -26
- package/resources/buddy-creator/assets/licenses/style-to-object.txt +0 -26
- package/resources/buddy-creator/assets/licenses/trim-lines.txt +0 -26
- package/resources/buddy-creator/assets/licenses/trough.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--debug.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--estree-jsx.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--estree.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--hast.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--mdast.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--ms.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--unist.txt +0 -25
- package/resources/buddy-creator/assets/licenses/ungap--structured-clone.txt +0 -19
- package/resources/buddy-creator/assets/licenses/unified.txt +0 -25
- package/resources/buddy-creator/assets/licenses/unist-util-is.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-position.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-stringify-position.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-visit-parents.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-visit.txt +0 -26
- package/resources/buddy-creator/assets/licenses/vfile-message.txt +0 -26
- package/resources/buddy-creator/assets/licenses/vfile.txt +0 -25
- package/resources/buddy-creator/assets/licenses/zwitch.txt +0 -26
- package/resources/buddy-creator/assets/preview/assets/index-B5VmkoSx.js +0 -39
- package/resources/buddy-creator/assets/preview/assets/index-DWKP6b-3.css +0 -4
- package/resources/buddy-creator/assets/preview/brand-logo.png +0 -0
- package/resources/buddy-creator/assets/preview/brand.css +0 -100
- package/resources/buddy-creator/assets/preview/index.html +0 -7
- package/resources/buddy-creator/assets/preview/reader.css +0 -165
- package/resources/buddy-creator/assets/preview/reader.js +0 -135
- package/resources/buddy-creator/assets/preview/widgets.css +0 -1
- package/resources/buddy-creator/assets/preview/widgets.js +0 -168
- package/resources/buddy-creator/assets/widget-reference/conversation.png +0 -0
- package/resources/buddy-creator/assets/widget-reference/expanded.png +0 -0
- package/resources/buddy-creator/assets/widget-v2/app.js +0 -62
- package/resources/buddy-creator/assets/widget-v2/style.css +0 -1
- package/resources/buddy-creator/assets/widget-v3/app.js +0 -65
- package/resources/buddy-creator/assets/widget-v3/style.css +0 -103
- package/resources/buddy-creator/assets/widget-v4/app.js +0 -66
- package/resources/buddy-creator/assets/widget-v4/style.css +0 -187
- package/resources/buddy-creator/references/artifact-schema.md +0 -70
- package/resources/buddy-creator/references/catalog.json +0 -401
- package/resources/buddy-creator/references/definition.md +0 -24
- package/resources/buddy-creator/references/dialogue-examples.md +0 -133
- package/resources/buddy-creator/references/host-guide.md +0 -219
- package/resources/buddy-creator/references/interview.md +0 -235
- package/resources/buddy-creator/references/knowledge.md +0 -40
- package/resources/buddy-creator/references/methods.md +0 -77
- package/resources/buddy-creator/references/opening.md +0 -14
- package/resources/buddy-creator/references/preview-panel.md +0 -66
- package/resources/buddy-creator/references/recovery.md +0 -46
- package/resources/buddy-creator/references/service.md +0 -76
- package/resources/buddy-creator/references/widgets-protocol.md +0 -83
- package/resources/buddy-creator/references/widgets.md +0 -99
- package/resources/buddy-creator/scripts/buddy.py +0 -108
- package/resources/buddy-creator/scripts/buddy_client.py +0 -67
- package/resources/buddy-creator/scripts/buddy_core.py +0 -865
- package/resources/buddy-creator/scripts/completion.py +0 -296
- package/resources/buddy-creator/scripts/interview.py +0 -215
- package/resources/buddy-creator/scripts/preview.py +0 -617
- package/resources/buddy-creator/scripts/preview_panel.py +0 -80
- package/resources/buddy-creator/scripts/sources.py +0 -239
- package/resources/buddy-creator/scripts/widget_render.py +0 -66
- package/resources/buddy-creator/scripts/widget_render_v1.py +0 -176
- package/resources/buddy-creator/scripts/widget_render_v2.py +0 -98
- package/resources/buddy-creator/scripts/widget_render_v3.py +0 -86
- package/resources/buddy-creator/scripts/widget_render_v4.py +0 -121
- package/resources/buddy-creator/scripts/widgets.py +0 -227
- package/resources/buddy-creator/version.json +0 -8
- package/resources/buddy-creator.manifest.json +0 -791
- package/resources/dada-object-avatar/SKILL.md +0 -37
- package/resources/dada-object-avatar/agents/openai.yaml +0 -4
- package/resources/dada-object-avatar/assets/examples/cat.png +0 -0
- package/resources/dada-object-avatar/assets/examples/cooking.png +0 -0
- package/resources/dada-object-avatar/assets/examples/divination.png +0 -0
- package/resources/dada-object-avatar/assets/examples/doctor.png +0 -0
- package/resources/dada-object-avatar/assets/examples/relationship.png +0 -0
- package/resources/dada-object-avatar/assets/examples/work.png +0 -0
- package/resources/dada-object-avatar/assets/preview.png +0 -0
- package/resources/dada-object-avatar/references/evaluation-rubric.md +0 -22
- package/resources/dada-object-avatar/references/examples.json +0 -8
- package/resources/dada-object-avatar/references/prompt-template.md +0 -27
- package/resources/dada-object-avatar/references/style-system.md +0 -54
- package/resources/dada-object-avatar/references/subject-design.md +0 -23
- package/resources/dada-object-avatar/references/training-guide.md +0 -14
- package/resources/dada-object-avatar/references/training-log.jsonl +0 -0
- package/resources/dada-object-avatar/references/training-record.schema.json +0 -20
- package/resources/dada-object-avatar/scripts/choose_gaze.py +0 -36
- package/resources/dada-object-avatar/scripts/record_example.py +0 -22
- package/resources/dada-object-avatar/scripts/validate_png.py +0 -52
- package/resources/dada-object-avatar.manifest.json +0 -85
|
@@ -1,865 +0,0 @@
|
|
|
1
|
-
"""Buddy Creator's deterministic, file-backed interview protocol (Python stdlib only).
|
|
2
|
-
|
|
3
|
-
The host supplies public assessments and text. This module never calls a model,
|
|
4
|
-
interprets external instructions, or infers consent from a keyword.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import copy
|
|
9
|
-
import hashlib
|
|
10
|
-
import json
|
|
11
|
-
import math
|
|
12
|
-
import os
|
|
13
|
-
import re
|
|
14
|
-
import tempfile
|
|
15
|
-
from contextlib import contextmanager
|
|
16
|
-
from datetime import datetime, timezone
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
-
METADATA = json.loads((ROOT / 'version.json').read_text(encoding='utf-8'))
|
|
21
|
-
SCHEMA = METADATA['workspaceFormat']
|
|
22
|
-
READABLE_SCHEMAS = {SCHEMA, 'buddy-python-trial-1'}
|
|
23
|
-
CLOSED = {'sufficient', 'uncertain', 'skipped', 'exhausted'}
|
|
24
|
-
KINDS = {'chapter', 'hypothesis', 'scenario', 'blueprint', 'transition'}
|
|
25
|
-
SOURCE_KINDS = {'file', 'webpage', 'history', 'mindmap', 'skill', 'oral', 'scan', 'audio', 'video'}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class BuddyError(Exception):
|
|
29
|
-
def __init__(self, code, message, details=None):
|
|
30
|
-
super().__init__(message)
|
|
31
|
-
self.code, self.details = code, details
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def require(value, code, message, details=None):
|
|
35
|
-
if not value:
|
|
36
|
-
raise BuddyError(code, message, details)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def now():
|
|
40
|
-
return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def digest(value):
|
|
44
|
-
value = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
45
|
-
return hashlib.sha256(value.encode('utf-8')).hexdigest()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def catalog():
|
|
49
|
-
return json.loads((ROOT / 'references' / 'catalog.json').read_text(encoding='utf-8'))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def atomic_json(path, data):
|
|
53
|
-
path = Path(path)
|
|
54
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
-
fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
|
|
56
|
-
try:
|
|
57
|
-
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
|
58
|
-
json.dump(data, handle, ensure_ascii=False, indent=2, allow_nan=False)
|
|
59
|
-
handle.write('\n')
|
|
60
|
-
handle.flush()
|
|
61
|
-
os.fsync(handle.fileno())
|
|
62
|
-
os.replace(temporary, path)
|
|
63
|
-
if os.name != 'nt':
|
|
64
|
-
directory = os.open(str(path.parent), os.O_RDONLY)
|
|
65
|
-
try:
|
|
66
|
-
os.fsync(directory)
|
|
67
|
-
finally:
|
|
68
|
-
os.close(directory)
|
|
69
|
-
finally:
|
|
70
|
-
if os.path.exists(temporary):
|
|
71
|
-
os.unlink(temporary)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@contextmanager
|
|
75
|
-
def workspace_lock(workspace, create=False):
|
|
76
|
-
"""OS-owned lock is released on process exit, including a killed process."""
|
|
77
|
-
workspace = Path(workspace).resolve()
|
|
78
|
-
if create:
|
|
79
|
-
workspace.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
else:
|
|
81
|
-
require((workspace / 'state.json').is_file(), 'WORKSPACE_NOT_FOUND', '这里没有 Buddy Creator 项目。请用 open 创建,或指定之前返回的 workspace。')
|
|
82
|
-
with (workspace / '.buddy.lock').open('a+b') as handle:
|
|
83
|
-
try:
|
|
84
|
-
if os.name == 'nt':
|
|
85
|
-
import msvcrt
|
|
86
|
-
handle.seek(0)
|
|
87
|
-
if not handle.read(1):
|
|
88
|
-
handle.write(b'0')
|
|
89
|
-
handle.flush()
|
|
90
|
-
handle.seek(0)
|
|
91
|
-
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
92
|
-
else:
|
|
93
|
-
import fcntl
|
|
94
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
95
|
-
except OSError as error:
|
|
96
|
-
raise BuddyError('WORKSPACE_BUSY', '另一个操作正在保存此搭子;保持同一操作编号,稍后重试。') from error
|
|
97
|
-
try:
|
|
98
|
-
yield
|
|
99
|
-
finally:
|
|
100
|
-
if os.name == 'nt':
|
|
101
|
-
handle.seek(0)
|
|
102
|
-
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
103
|
-
else:
|
|
104
|
-
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def load(workspace):
|
|
108
|
-
path = Path(workspace) / 'state.json'
|
|
109
|
-
require(path.is_file(), 'WORKSPACE_NOT_FOUND', '这里没有 Buddy Creator 项目。请用 open 创建,或指定之前返回的 workspace。')
|
|
110
|
-
try:
|
|
111
|
-
state = json.loads(path.read_text(encoding='utf-8'))
|
|
112
|
-
except (ValueError, OSError) as error:
|
|
113
|
-
raise BuddyError('STATE_UNREADABLE', '项目状态无法读取;历史版本保留在 revisions/,请先保留现场再恢复,不要重新创建覆盖。') from error
|
|
114
|
-
require(state.get('schemaVersion') in READABLE_SCHEMAS, 'WORKSPACE_FORMAT', '此版本支持 Buddy Creator 1.x 和此前 Python 试用项目;Node 0.x 的项目需要另建目录,不能直接迁移检查点。')
|
|
115
|
-
return state
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def save(workspace, state):
|
|
119
|
-
state['updatedAt'] = now()
|
|
120
|
-
state['revision'] = 'rev_' + digest({k: v for k, v in state.items() if k != 'revision'})[:32]
|
|
121
|
-
atomic_json(Path(workspace) / 'revisions' / (state['revision'] + '.json'), state)
|
|
122
|
-
atomic_json(Path(workspace) / 'state.json', state)
|
|
123
|
-
return state
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def book_ids(stage):
|
|
127
|
-
return ['%s.%s' % (stage, i + 1) for i, _ in enumerate(catalog()['chapters'][stage])]
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def confirmed(state, object_id, decision='confirmed'):
|
|
131
|
-
artifact = state['artifacts'].get(object_id)
|
|
132
|
-
return bool(artifact and any(c['objectId'] == object_id and c['hash'] == artifact['hash'] and c['decision'] == decision and not c.get('invalidatedBy') for c in state['confirmations']))
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def book_confirmed(state, stage):
|
|
136
|
-
return all(confirmed(state, key) for key in book_ids(stage))
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def choose_stage(state):
|
|
140
|
-
return next((s for s in catalog()['stages'] if not book_confirmed(state, s)), 'service')
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def verify_ref(state, ref):
|
|
144
|
-
require(isinstance(ref, dict), 'EVIDENCE_SCHEMA', '证据必须为 type、id、hash 组成的对象。')
|
|
145
|
-
kind, key = ref.get('type'), ref.get('id')
|
|
146
|
-
if kind == 'input':
|
|
147
|
-
item = state['inputs'].get(key)
|
|
148
|
-
require(item and item['hash'] == ref.get('hash'), 'EVIDENCE_VERSION', '用户原话不存在或哈希不匹配。', {'id': key})
|
|
149
|
-
text = item['raw']
|
|
150
|
-
require(isinstance(ref.get('quote'), str) and ref['quote'].strip(), 'EVIDENCE_QUOTE', '用户原话证据必须包含非空精确引用。')
|
|
151
|
-
elif kind == 'artifact':
|
|
152
|
-
item = state['artifacts'].get(key)
|
|
153
|
-
require(item and item['hash'] == ref.get('hash'), 'EVIDENCE_VERSION', '产物证据不是当前已保存版本。', {'id': key})
|
|
154
|
-
text = item['markdown']
|
|
155
|
-
elif kind == 'source':
|
|
156
|
-
source = state['sources'].get(key)
|
|
157
|
-
require(source and source.get('status') == 'ready', 'SOURCE_NOT_READY', '所引用的来源尚未完整可用。', {'id': key})
|
|
158
|
-
item = next((c for c in source.get('chunks', []) if c.get('hash') == ref.get('hash') and c.get('locator') == ref.get('locator')), None)
|
|
159
|
-
require(item and digest(item['text']) == item['hash'], 'EVIDENCE_LOCATOR', '来源必须绑定真实文本块的哈希及 locator。', {'id': key})
|
|
160
|
-
text = item['text']
|
|
161
|
-
else:
|
|
162
|
-
raise BuddyError('EVIDENCE_SCHEMA', '证据 type 只能为 input、source、artifact。')
|
|
163
|
-
if ref.get('quote') is not None:
|
|
164
|
-
require(isinstance(ref['quote'], str) and ref['quote'] in text, 'EVIDENCE_QUOTE', '引用必须与已存原文一致。', {'id': key})
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def evidence(state, refs, input_id=None):
|
|
168
|
-
require(isinstance(refs, list), 'EVIDENCE_SCHEMA', 'evidence/dependencies 必须是数组。')
|
|
169
|
-
for ref in refs:
|
|
170
|
-
verify_ref(state, ref)
|
|
171
|
-
if input_id:
|
|
172
|
-
require(any(r.get('type') == 'input' and r.get('id') == input_id and r.get('quote', '').strip() for r in refs), 'USER_EVIDENCE_REQUIRED', '此动作必须引用本轮用户原话,不能从宿主推测用户已认可。')
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def text(value):
|
|
176
|
-
return isinstance(value, str) and bool(value.strip())
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def filled(value, keys):
|
|
180
|
-
return isinstance(value, dict) and all(text(value.get(k)) for k in keys)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def text_list(value, empty=False):
|
|
184
|
-
return isinstance(value, list) and (empty or bool(value)) and all(text(v) for v in value)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def service_gates(state):
|
|
188
|
-
data = state['artifacts'].get('service.blueprint', {}).get('data', {})
|
|
189
|
-
stages, transitions = data.get('stages', {}), data.get('transitions', {})
|
|
190
|
-
loop, contract = data.get('serviceLoop', {}), data.get('acquisitionContract', {})
|
|
191
|
-
checks = data.get('semanticChecks', {})
|
|
192
|
-
def known(key):
|
|
193
|
-
check = checks.get(key, {})
|
|
194
|
-
return isinstance(check, dict) and check.get('pass') is True and bool(check.get('evidence'))
|
|
195
|
-
period = data.get('billingCycle', {})
|
|
196
|
-
valid_period = isinstance(period, dict) and set(period) == {'count', 'unit'} and type(period.get('count')) is int and period['count'] > 0 and period.get('unit') in {'day', 'week', 'month', 'year'}
|
|
197
|
-
try:
|
|
198
|
-
price = float(data.get('price', 0))
|
|
199
|
-
valid_price = type(data.get('price')) is not bool and math.isfinite(price) and price > 0
|
|
200
|
-
except (ValueError, TypeError, OverflowError):
|
|
201
|
-
valid_price = False
|
|
202
|
-
path_ids = ['acquisition-paid', 'acquisition-maintenance', 'paid-paid', 'paid-maintenance', 'maintenance-paid']
|
|
203
|
-
maintenance = data.get('maintenanceContract', {})
|
|
204
|
-
rows = [
|
|
205
|
-
('持续价值', text(data.get('valueStatement')) and text_list(data.get('recurrenceDrivers')) and text_list(data.get('renewalEvidence')) and text(loop.get('nextCycleUpdate'))),
|
|
206
|
-
('完整服务循环', filled(loop, ['trigger', 'requiredInput', 'decision', 'action', 'result', 'feedback', 'nextCycleUpdate']) and known('coherentLoop')),
|
|
207
|
-
('付费可交付', filled(stages.get('paid'), ['result', 'service', 'limit']) and text(data.get('deliveries')) and known('paidDeliverable')),
|
|
208
|
-
('免费体验范围明确', filled(contract, ['trigger', 'requiredInput', 'completionCriteria', 'conversionBridge']) and text_list(contract.get('includedSteps')) and text_list(contract.get('excludedSteps'), True) and known('freeScopeAgreed')),
|
|
209
|
-
('停付保留基础对话', filled(stages.get('maintenance'), ['service', 'limit']) and maintenance.get('basicConversation') is True and text(maintenance.get('allowedQuestions')) and known('maintenanceBoundary')),
|
|
210
|
-
('五条动线', all(filled(transitions.get(key), ['trigger', 'rightsChange', 'dataInheritance', 'message']) for key in path_ids) and known('transitionsConfirmed') and (state.get('serviceMode') != 'guided' or all(confirmed(state, 'transition.' + key) and state['artifacts']['transition.' + key].get('data') == transitions.get(key) for key in path_ids[:-1]))),
|
|
211
|
-
('价值后提示付费', known('valueBeforePaywall')),
|
|
212
|
-
('创作者复核有产能', data.get('humanReview') is False or data.get('humanReview') is True and text(data.get('reviewCapacity')) and known('creatorCapacity')),
|
|
213
|
-
('单档自定义周期订阅', valid_period and valid_price and data.get('platformRules') == catalog()['serviceRules'] and data.get('multiUser') is False and text(data.get('cadence')) and data.get('conversationLimit') == 'unlimited' and text(data.get('toolLimit')) and (data.get('experienceMode') == 'conversation' or data.get('experienceMode') == 'custom-component' and text(data.get('customComponentDescription'))) and known('executableContract')),
|
|
214
|
-
]
|
|
215
|
-
return [{'label': label, 'pass': bool(passed)} for label, passed in rows]
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def gates(state, stage):
|
|
219
|
-
cards = catalog()['cards']
|
|
220
|
-
def target_rows(ids):
|
|
221
|
-
return [{'label': cards[key]['title'], 'pass': state['targets'][key]['status'] in CLOSED} for key in ids]
|
|
222
|
-
if stage == 'definition':
|
|
223
|
-
return target_rows([k for k, card in cards.items() if card['stage'] == stage])
|
|
224
|
-
if stage == 'knowledge':
|
|
225
|
-
plan = state['sourcePlan']
|
|
226
|
-
selected = [state['sources'].get(key) for key in plan['sourceIds']]
|
|
227
|
-
return target_rows(['K01', 'K03', 'K04']) + [
|
|
228
|
-
{'label': '来源发现已明确结束', 'pass': plan['discoveryClosed']},
|
|
229
|
-
{'label': '导入计划有实际内容', 'pass': bool(selected and plan['requiredKinds'])},
|
|
230
|
-
{'label': '全部所选来源完整可用', 'pass': all(s and s.get('status') == 'ready' and s.get('chunks') for s in selected)},
|
|
231
|
-
{'label': '每类必需来源有实际内容', 'pass': all(any(s and s.get('kind') == kind and s.get('status') == 'ready' for s in selected) for kind in plan['requiredKinds'])},
|
|
232
|
-
]
|
|
233
|
-
if stage == 'methods':
|
|
234
|
-
hypotheses = [a for a in state['artifacts'].values() if a['kind'] == 'hypothesis']
|
|
235
|
-
return [
|
|
236
|
-
{'label': '3—4 个不同方法候选已全部处理', 'pass': 3 <= len(hypotheses) <= 4 and len({re.sub(r'\s', '', a['markdown']) for a in hypotheses}) == len(hypotheses) and all(confirmed(state, a['id'], 'accepted') or confirmed(state, a['id'], 'rejected') for a in hypotheses)},
|
|
237
|
-
{'label': '至少一个方法获认可', 'pass': any(confirmed(state, a['id'], 'accepted') for a in hypotheses)},
|
|
238
|
-
] + [{'label': cards[key]['title'], 'pass': confirmed(state, 'scenario.' + key)} for key in ['M01', 'M02', 'M03', 'M04', 'E01', 'E02', 'E03']]
|
|
239
|
-
return service_gates(state)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def can_draft(state, stage):
|
|
243
|
-
import interview
|
|
244
|
-
return not interview.blockers(state, stage) and all(g['pass'] for g in gates(state, stage)) and (stage != 'service' or confirmed(state, 'service.blueprint'))
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def askable(state, target_id):
|
|
248
|
-
import interview
|
|
249
|
-
cards = catalog()['cards']
|
|
250
|
-
require(target_id in cards, 'UNKNOWN_TARGET', '未知采访目标。', {'targetId': target_id})
|
|
251
|
-
card, target = cards[target_id], state['targets'][target_id]
|
|
252
|
-
require(not state['paused'], 'PAUSED', '用户已暂停,先按用户原话恢复再提出新问题。')
|
|
253
|
-
require(card['stage'] == state['stage'], 'STAGE_SCOPE', '不能提前采访其他阶段。')
|
|
254
|
-
require(target['status'] not in CLOSED, 'FOLLOWUP_CLOSED', '该目标已收敛或暂放;请整理已有内容,不能反复追问。', {'targetId': target_id})
|
|
255
|
-
interview.target_open(state, target_id)
|
|
256
|
-
if target_id == 'K02':
|
|
257
|
-
require(not state['sourcePlan']['discoveryClosed'], 'SOURCE_DISCOVERY_CLOSED', '用户已结束本轮来源发现,不应重复询问。')
|
|
258
|
-
if card['stage'] == 'methods':
|
|
259
|
-
if target_id.startswith('H'):
|
|
260
|
-
key = 'hypothesis.H' + str(int(target_id[1:]))
|
|
261
|
-
require(key in state['artifacts'] and not confirmed(state, key, 'accepted') and not confirmed(state, key, 'rejected'), 'HYPOTHESIS_HANDLED', '候选尚未生成或已处理。')
|
|
262
|
-
else:
|
|
263
|
-
require(all(g['pass'] for g in gates(state, 'methods')[:2]), 'METHOD_HYPOTHESES', '请先完成全部方法候选校准,再讨论基础场景。')
|
|
264
|
-
if target_id.startswith('E'):
|
|
265
|
-
require(all(confirmed(state, 'scenario.M0' + str(i)) for i in range(1, 5)), 'BASE_SCENARIOS', '请先确认四个基础案例。')
|
|
266
|
-
require(not confirmed(state, 'scenario.' + target_id), 'SCENARIO_HANDLED', '当前场景已经确认。')
|
|
267
|
-
if card['stage'] == 'service' and target_id not in {'S00', 'R00'}:
|
|
268
|
-
require(state.get('serviceModelExplained') and state.get('serviceMode') in {'smart', 'guided'}, 'SERVICE_INTRO', '先用通俗语言解释三阶段,再由用户选择智能规划或逐项讨论。')
|
|
269
|
-
if target_id == 'R00':
|
|
270
|
-
require(all(book_confirmed(state, s) for s in catalog()['stages']), 'REVISION_ENTRY', '先完成当前手册。')
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def invalidate(state, changed, by):
|
|
274
|
-
changed = set(changed)
|
|
275
|
-
while True:
|
|
276
|
-
if any(key.startswith('transition.') for key in changed):
|
|
277
|
-
changed.add('service.blueprint')
|
|
278
|
-
if 'service.blueprint' in changed:
|
|
279
|
-
changed.update(book_ids('service'))
|
|
280
|
-
if any(key.startswith(('hypothesis.', 'scenario.')) for key in changed):
|
|
281
|
-
changed.update(book_ids('methods'))
|
|
282
|
-
dependent = {a['id'] for a in state['artifacts'].values() if any(r.get('type') == 'artifact' and r.get('id') in changed for r in a.get('dependencies', []))}
|
|
283
|
-
newer = changed | dependent
|
|
284
|
-
if newer == changed:
|
|
285
|
-
break
|
|
286
|
-
changed = newer
|
|
287
|
-
for record in state['confirmations']:
|
|
288
|
-
if record['objectId'] in changed and not record.get('invalidatedBy'):
|
|
289
|
-
record['invalidatedBy'] = by
|
|
290
|
-
return changed
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def resolve_transition_refs(state, artifact):
|
|
294
|
-
"""Copy explicitly referenced, confirmed paths before hashing a new blueprint."""
|
|
295
|
-
transitions = artifact['data'].get('transitions', {})
|
|
296
|
-
if not isinstance(transitions, dict):
|
|
297
|
-
return
|
|
298
|
-
for path_id, value in list(transitions.items()):
|
|
299
|
-
if not isinstance(value, dict) or value.get('type') != 'artifact':
|
|
300
|
-
continue
|
|
301
|
-
require(set(value) == {'type', 'id', 'hash'} and value['id'] == 'transition.' + path_id,
|
|
302
|
-
'TRANSITION_REFERENCE', '路径引用使用对应 transition 产物的 {type:"artifact",id,hash}。', {'pathId': path_id})
|
|
303
|
-
verify_ref(state, value)
|
|
304
|
-
source = state['artifacts'][value['id']]
|
|
305
|
-
require(source['kind'] == 'transition' and confirmed(state, source['id']),
|
|
306
|
-
'UNCONFIRMED_DEPENDENCY', '只能复用当前版本已确认的服务路径。', {'id': source['id']})
|
|
307
|
-
transitions[path_id] = copy.deepcopy(source['data'])
|
|
308
|
-
require(isinstance(artifact['dependencies'], list), 'EVIDENCE_SCHEMA', 'dependencies 必须是数组。')
|
|
309
|
-
if value not in artifact['dependencies']:
|
|
310
|
-
artifact['dependencies'].append(copy.deepcopy(value))
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def put_artifacts(state, proposals, by, draft=False):
|
|
314
|
-
import widgets
|
|
315
|
-
require(isinstance(proposals, list), 'PATCH_SCHEMA', 'artifacts 必须为数组。')
|
|
316
|
-
ids = [a.get('id') for a in proposals if isinstance(a, dict)]
|
|
317
|
-
require(len(ids) == len(proposals) and len(set(ids)) == len(ids), 'PATCH_SCHEMA', '每个产物必须是对象,且一次只能修改同一 ID 一次。')
|
|
318
|
-
changed = set()
|
|
319
|
-
stages = catalog()['stages']
|
|
320
|
-
for item in proposals:
|
|
321
|
-
required = {'id', 'stage', 'kind', 'title', 'markdown', 'evidence', 'dependencies', 'unresolved'}
|
|
322
|
-
require(required <= set(item) and not set(item) - (required | {'data'}), 'ARTIFACT_SCHEMA', '产物字段必须包含 id、stage、kind、title、markdown、evidence、dependencies、unresolved,可选 data;不要提交 hash/revision。')
|
|
323
|
-
if item['kind'] in widgets.KINDS:
|
|
324
|
-
require(text(item['title']) and text(item['markdown']) and text_list(item['unresolved'], True), 'ARTIFACT_SCHEMA', '小挂件产物需要标题、说明及待补项。')
|
|
325
|
-
a = copy.deepcopy(item)
|
|
326
|
-
require(isinstance(a.get('data'), dict), 'ARTIFACT_DATA', '小挂件需要结构化 data。')
|
|
327
|
-
evidence(state, a['evidence'])
|
|
328
|
-
evidence(state, a['dependencies'])
|
|
329
|
-
for ref in a['dependencies']:
|
|
330
|
-
if ref['type'] == 'artifact':
|
|
331
|
-
require(confirmed(state, ref['id']), 'UNCONFIRMED_DEPENDENCY', '依赖内容必须已确认。')
|
|
332
|
-
widgets.validate(state, a)
|
|
333
|
-
a['hash'] = digest(a)
|
|
334
|
-
if state['artifacts'].get(a['id'], {}).get('hash') != a['hash']:
|
|
335
|
-
changed.add(a['id'])
|
|
336
|
-
state['artifacts'][a['id']] = a
|
|
337
|
-
continue
|
|
338
|
-
require(item['stage'] in stages and item['kind'] in KINDS and text(item['title']) and text(item['markdown']) and text_list(item['unresolved'], True), 'ARTIFACT_SCHEMA', '产物结构不完整。')
|
|
339
|
-
require(stages.index(item['stage']) <= stages.index(state['stage']), 'STAGE_SCOPE', '上游手册尚未确认,不能生成后续阶段产物。')
|
|
340
|
-
a = copy.deepcopy(item)
|
|
341
|
-
a.setdefault('data', {})
|
|
342
|
-
require(isinstance(a['data'], dict), 'ARTIFACT_DATA', 'data 必须为对象。')
|
|
343
|
-
if a['kind'] == 'blueprint':
|
|
344
|
-
resolve_transition_refs(state, a)
|
|
345
|
-
evidence(state, a['evidence'])
|
|
346
|
-
evidence(state, a['dependencies'])
|
|
347
|
-
require(a['evidence'] or a['dependencies'], 'EVIDENCE_REQUIRED', '产物必须记录实际依据,不能保存没有来源的结论。')
|
|
348
|
-
for stage in stages[:stages.index(a['stage'])]:
|
|
349
|
-
require(any(r.get('type') == 'artifact' and state['artifacts'][r['id']]['stage'] == stage for r in a['dependencies']), 'DEPENDENCY_REQUIRED', '下游产物必须引用每个上游阶段实际使用的已确认内容。', {'requiredStage': stage, 'id': a['id']})
|
|
350
|
-
for ref in a['dependencies']:
|
|
351
|
-
if ref['type'] == 'artifact':
|
|
352
|
-
require(confirmed(state, ref['id']) or confirmed(state, ref['id'], 'accepted'), 'UNCONFIRMED_DEPENDENCY', '依赖内容必须已确认。', {'id': ref['id']})
|
|
353
|
-
if a['kind'] == 'chapter':
|
|
354
|
-
require(a['id'] in book_ids(a['stage']), 'CHAPTER_SCHEMA', '章节必须使用固定的 27 章编号。')
|
|
355
|
-
elif a['kind'] == 'hypothesis':
|
|
356
|
-
require(a['stage'] == 'methods' and re.fullmatch(r'hypothesis\.H[1-4]', a['id']), 'HYPOTHESIS_SCHEMA', '方法候选使用 hypothesis.H1 至 H4。')
|
|
357
|
-
elif a['kind'] == 'scenario':
|
|
358
|
-
require(a['stage'] == 'methods' and re.fullmatch(r'scenario\.(M0[1-4]|E0[1-3])', a['id']), 'SCENARIO_SCHEMA', '案例使用 scenario.M01—M04 / E01—E03。')
|
|
359
|
-
require(all(g['pass'] for g in gates(state, 'methods')[:2]), 'METHOD_HYPOTHESES', '先校准方法候选。')
|
|
360
|
-
if a['id'].startswith('scenario.M'):
|
|
361
|
-
require(any(r['type'] == 'input' for r in a['evidence']), 'USER_ANSWERS_FIRST', '基础案例必须先有用户实际回答。')
|
|
362
|
-
else:
|
|
363
|
-
require(all(confirmed(state, 'scenario.M0' + str(i)) for i in range(1, 5)), 'BASE_SCENARIOS', '拓展案例前先确认四个基础案例。')
|
|
364
|
-
require(any(r['type'] == 'artifact' and r['id'].startswith('scenario.M') for r in a['dependencies']), 'BASE_SCENARIO_REQUIRED', '拓展案例须引用对应基础案例。')
|
|
365
|
-
elif a['kind'] == 'transition':
|
|
366
|
-
require(a['stage'] == 'service' and a['id'] in {'transition.' + k for k in ['acquisition-paid', 'acquisition-maintenance', 'paid-paid', 'paid-maintenance']}, 'TRANSITION_SCHEMA', '仅四条非默认路径单独校准。')
|
|
367
|
-
require(filled(a['data'], ['trigger', 'rightsChange', 'dataInheritance', 'message']), 'TRANSITION_INCOMPLETE', '路径须包含时点、权益、历史继承和表达。')
|
|
368
|
-
elif a['kind'] == 'blueprint':
|
|
369
|
-
require(a['id'] == 'service.blueprint' and a['stage'] == 'service', 'BLUEPRINT_SCHEMA', '服务蓝图 ID 必须为 service.blueprint。')
|
|
370
|
-
data = a['data']
|
|
371
|
-
require(data.get('platformRules') == catalog()['serviceRules'], 'FIXED_SERVICE_RULES', 'platformRules 必须复制 context.catalog.serviceRules 当前规则。')
|
|
372
|
-
require(data.get('conversationLimit', 'unlimited') == 'unlimited' and data.get('maintenanceContract', {}).get('basicConversation', True) is True, 'SERVICE_CONVERSATION_POLICY', '付费期间 AI 对话不限次数,维持期间保留基础对话。')
|
|
373
|
-
period = data.get('billingCycle')
|
|
374
|
-
if period is not None:
|
|
375
|
-
require(isinstance(period, dict) and set(period) == {'count', 'unit'} and type(period.get('count')) is int and period['count'] > 0 and period.get('unit') in {'day', 'week', 'month', 'year'}, 'SUBSCRIPTION_PERIOD', 'billingCycle 使用 {count:正整数,unit:day|week|month|year}。')
|
|
376
|
-
for check in data.get('semanticChecks', {}).values():
|
|
377
|
-
require(isinstance(check, dict) and isinstance(check.get('pass'), bool), 'SERVICE_EVIDENCE', 'semanticChecks 每项必须包含 pass 和 evidence。')
|
|
378
|
-
evidence(state, check.get('evidence'))
|
|
379
|
-
if check['pass']:
|
|
380
|
-
require(check['evidence'], 'SERVICE_EVIDENCE', '通过的业务判断需要真实依据;不能空写 pass:true。')
|
|
381
|
-
a['hash'] = digest(a)
|
|
382
|
-
if state['artifacts'].get(a['id'], {}).get('hash') != a['hash']:
|
|
383
|
-
changed.add(a['id'])
|
|
384
|
-
state['artifacts'][a['id']] = a
|
|
385
|
-
invalidate(state, changed, by)
|
|
386
|
-
state['stage'] = choose_stage(state)
|
|
387
|
-
return sorted(changed)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def record_confirmations(state, turn, records, modifying):
|
|
391
|
-
require(isinstance(records, list), 'CONFIRMATION_SCHEMA', 'confirmations 必须为数组。')
|
|
392
|
-
if not records:
|
|
393
|
-
return
|
|
394
|
-
input_item = state['inputs'][turn['inputId']]
|
|
395
|
-
delivery = state['deliveries'].get(input_item.get('replyToDeliveryId'))
|
|
396
|
-
require(delivery and delivery['id'] in state['presentations'] and delivery.get('confirmationTarget'), 'CONFIRMATION_NOT_SHOWN', '用户尚未看到可核对的确切版本;先展示待确认内容,下轮引用用户回复。')
|
|
397
|
-
shown = {o['id']: o['hash'] for o in delivery['confirmationTarget']['objects']}
|
|
398
|
-
for record in records:
|
|
399
|
-
key = record.get('objectId')
|
|
400
|
-
require(key not in modifying, 'UNSEEN_CONFIRMATION', '同一轮修改和确认不能作用于同一产物;新版须展示后再确认。')
|
|
401
|
-
evidence(state, record.get('evidence'), turn['inputId'])
|
|
402
|
-
artifact = state['artifacts'].get(key)
|
|
403
|
-
require(artifact and key in shown and artifact['hash'] == shown[key], 'CONFIRMATION_STALE', '确认只能对应上一条实际展示的当前版本,不能扩大范围或确认旧版。')
|
|
404
|
-
require_confirmation_ready(state, [artifact])
|
|
405
|
-
allowed = {'accepted', 'rejected'} if artifact['kind'] == 'hypothesis' else {'confirmed'}
|
|
406
|
-
require(record.get('decision') in allowed, 'DECISION_SCOPE', '方法候选使用 accepted/rejected,其他产物使用 confirmed。')
|
|
407
|
-
entry = dict(record, id='confirmation_' + digest([turn['inputId'], key, artifact['hash'], record['decision']])[:24], hash=artifact['hash'], inputId=turn['inputId'], deliveryId=delivery['id'])
|
|
408
|
-
if not any(c['id'] == entry['id'] for c in state['confirmations']):
|
|
409
|
-
state['confirmations'].append(entry)
|
|
410
|
-
state['stage'] = choose_stage(state)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def transition_differences(state):
|
|
414
|
-
differences = []
|
|
415
|
-
if state.get('serviceMode') != 'guided':
|
|
416
|
-
return differences
|
|
417
|
-
actual_paths = state['artifacts'].get('service.blueprint', {}).get('data', {}).get('transitions', {})
|
|
418
|
-
for path_id in ['acquisition-paid', 'acquisition-maintenance', 'paid-paid', 'paid-maintenance']:
|
|
419
|
-
key = 'transition.' + path_id
|
|
420
|
-
if not confirmed(state, key):
|
|
421
|
-
continue
|
|
422
|
-
expected = state['artifacts'][key]['data']
|
|
423
|
-
actual = actual_paths.get(path_id)
|
|
424
|
-
if not isinstance(actual, dict):
|
|
425
|
-
differences.append({'sourceArtifactId': key, 'field': 'transitions.' + path_id,
|
|
426
|
-
'expected': expected, 'actual': actual})
|
|
427
|
-
continue
|
|
428
|
-
for field in sorted(set(expected) | set(actual)):
|
|
429
|
-
if field not in expected or field not in actual or expected[field] != actual[field]:
|
|
430
|
-
differences.append({'sourceArtifactId': key, 'field': 'transitions.' + path_id + '.' + field,
|
|
431
|
-
'expected': expected.get(field), 'actual': actual.get(field),
|
|
432
|
-
'expectedPresent': field in expected, 'actualPresent': field in actual})
|
|
433
|
-
return differences
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def confirmation_blockers(state, artifact):
|
|
437
|
-
import widgets
|
|
438
|
-
if artifact['kind'] in widgets.KINDS:
|
|
439
|
-
return widgets.blockers(state, artifact)
|
|
440
|
-
import interview
|
|
441
|
-
result = {}
|
|
442
|
-
if artifact['unresolved']:
|
|
443
|
-
result['unresolved'] = artifact['unresolved']
|
|
444
|
-
target_id = ('H' + artifact['id'].split('H')[-1].zfill(2) if artifact['kind'] == 'hypothesis'
|
|
445
|
-
else artifact['id'].split('.', 1)[1] if artifact['kind'] == 'scenario' else None)
|
|
446
|
-
gaps = interview.blockers(state, artifact['stage'], target_id)
|
|
447
|
-
if artifact['kind'] == 'transition':
|
|
448
|
-
prefix = 'T.' + artifact['id'].split('.', 1)[1] + '.'
|
|
449
|
-
gaps = [gap for gap in gaps if gap['targetId'].startswith(prefix)]
|
|
450
|
-
if gaps:
|
|
451
|
-
result['blockingGaps'] = [{key: gap[key] for key in ('id', 'targetId', 'description', 'impact', 'status')} for gap in gaps]
|
|
452
|
-
if artifact['kind'] in {'chapter', 'blueprint'}:
|
|
453
|
-
failed = [gate for gate in gates(state, artifact['stage']) if not gate['pass']]
|
|
454
|
-
if failed:
|
|
455
|
-
result['failedGates'] = failed
|
|
456
|
-
required = []
|
|
457
|
-
if artifact['stage'] == 'service':
|
|
458
|
-
if artifact['kind'] == 'chapter' and not confirmed(state, 'service.blueprint'):
|
|
459
|
-
required.append('service.blueprint')
|
|
460
|
-
if state.get('serviceMode') == 'guided':
|
|
461
|
-
required.extend('transition.' + key for key in ['acquisition-paid', 'acquisition-maintenance', 'paid-paid', 'paid-maintenance']
|
|
462
|
-
if not confirmed(state, 'transition.' + key))
|
|
463
|
-
differences = transition_differences(state)
|
|
464
|
-
if differences:
|
|
465
|
-
result['transitionDifferences'] = differences
|
|
466
|
-
if required:
|
|
467
|
-
result['requiredConfirmations'] = required
|
|
468
|
-
return result
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def confirmation_ready(state, artifact):
|
|
472
|
-
return not confirmation_blockers(state, artifact)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def require_confirmation_ready(state, artifacts):
|
|
476
|
-
blocked = []
|
|
477
|
-
for artifact in artifacts:
|
|
478
|
-
reasons = confirmation_blockers(state, artifact)
|
|
479
|
-
if reasons:
|
|
480
|
-
blocked.append({'objectId': artifact['id'], 'stage': artifact['stage'], **reasons})
|
|
481
|
-
require(not blocked, 'CONFIRMATION_BLOCKED', '当前版本尚不能确认;请按 details.objects 中的具体缺口修正,沿用同一 turnId 重试。',
|
|
482
|
-
{'objects': blocked})
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def delivery_record(state, proposal, delivery_id, input_id=None, opening=False, explanation=False):
|
|
486
|
-
import interview
|
|
487
|
-
import widgets
|
|
488
|
-
require(isinstance(proposal, dict) and text(proposal.get('text')), 'DELIVERY_REQUIRED', '需要给用户的公开回复 text。')
|
|
489
|
-
require(not set(proposal) - {'text', 'question', 'designQuestion', 'confirmationObjectIds', 'confirmationScope', 'mode', 'blocked'}, 'DELIVERY_SCHEMA', 'delivery 包含未支持字段;请按协议提交。')
|
|
490
|
-
question, ids = proposal.get('question'), proposal.get('confirmationObjectIds')
|
|
491
|
-
design_question = proposal.get('designQuestion')
|
|
492
|
-
if explanation:
|
|
493
|
-
require(not question and not ids and not design_question and not proposal.get('blocked'), 'EXPLANATION_ONLY', '纯答疑保留原待答内容,不附加采访问题或确认。')
|
|
494
|
-
require(sum(bool(v) for v in (question, ids, design_question)) <= 1, 'ONE_REPLY_TARGET', '每轮一个核心问题或一次确认,不要同时提交两种。')
|
|
495
|
-
result = {'id': delivery_id, 'text': proposal['text'], 'hash': digest(proposal['text']), 'createdAt': now(), 'mode': proposal.get('mode', 'ordinary'), 'inputId': input_id}
|
|
496
|
-
# The host checks one semantic reply target. Quoted questions and context
|
|
497
|
-
# length cannot reliably be judged by punctuation or character counts.
|
|
498
|
-
if design_question:
|
|
499
|
-
require(text(design_question) and widgets.enabled(state) and widgets.books_ready(state), 'WIDGET_STAGE', '小挂件补充问题在四册确认后提出,designQuestion 记录一个具体缺口。')
|
|
500
|
-
result['designQuestion'] = design_question
|
|
501
|
-
elif question:
|
|
502
|
-
require(isinstance(question, dict) and 'targetId' in question and not set(question) - {'targetId', 'gapId'}, 'QUESTION_SCHEMA', 'question 使用 {targetId, gapId?}。')
|
|
503
|
-
askable(state, question['targetId'])
|
|
504
|
-
if result['mode'] == 'transition':
|
|
505
|
-
require(question['targetId'].startswith('T.'), 'TRANSITION_TARGET', 'transition 模式绑定当前服务路径的一个决定;自然表达,不要求固定段落。')
|
|
506
|
-
result['question'] = interview.question_gap(state, question) if interview.tracked(question['targetId']) else dict(question)
|
|
507
|
-
elif ids:
|
|
508
|
-
require(isinstance(ids, list) and ids and len(set(ids)) == len(ids), 'CONFIRMATION_SCHEMA', 'confirmationObjectIds 使用不重复的产物 ID 数组。')
|
|
509
|
-
artifacts = [state['artifacts'].get(key) for key in ids]
|
|
510
|
-
require(all(artifacts), 'CONFIRMATION_MISSING', '请先保存确切的待确认版本。')
|
|
511
|
-
require_confirmation_ready(state, artifacts)
|
|
512
|
-
require(len({a['stage'] for a in artifacts}) == 1, 'CONFIRMATION_SCOPE', '一次不能跨阶段确认。')
|
|
513
|
-
require(not any(a['kind'] == 'hypothesis' for a in artifacts) or len(artifacts) == 1, 'HYPOTHESIS_ONE_AT_A_TIME', '方法候选每轮只展示并校准一条。')
|
|
514
|
-
scope = proposal.get('confirmationScope', 'object')
|
|
515
|
-
require(scope in {'object', 'booklet'}, 'CONFIRMATION_SCOPE', 'confirmationScope 使用 object 或 booklet。')
|
|
516
|
-
if scope == 'booklet':
|
|
517
|
-
require(artifacts[0]['stage'] in catalog()['stages'], 'CONFIRMATION_SCOPE', '小挂件使用 object 确认,不新增第五册。')
|
|
518
|
-
require(set(ids) == set(book_ids(artifacts[0]['stage'])), 'BOOKLET_INCOMPLETE', '整册确认必须包含该册全部固定章节。')
|
|
519
|
-
result['confirmationTarget'] = {'scope': scope, 'stage': artifacts[0]['stage'], 'objects': [{'id': a['id'], 'hash': a['hash']} for a in artifacts]}
|
|
520
|
-
blocked = proposal.get('blocked')
|
|
521
|
-
if blocked is not None:
|
|
522
|
-
require(text(blocked) and not question and not ids and not design_question, 'BLOCKED_SCHEMA', 'blocked 是具体阻塞及恢复条件,只在无合法下一步时使用。')
|
|
523
|
-
options = available(state)
|
|
524
|
-
require(not options['questionTargets'] and not options['confirmationObjects'] and not options['canDraftBooklet'],
|
|
525
|
-
'NOT_BLOCKED', '还有可提问目标、可确认内容或可生成手册,须继续推进。')
|
|
526
|
-
require(not all(book_confirmed(state, s) for s in catalog()['stages']), 'NOT_BLOCKED', '访谈已完成,不是阻塞。')
|
|
527
|
-
result['blocked'] = blocked
|
|
528
|
-
state.pop('questionDeliveryId', None)
|
|
529
|
-
if not explanation and not question and not ids and not design_question and not blocked and not state['paused'] and (not all(book_confirmed(state, s) for s in catalog()['stages']) or not widgets.ready(state)):
|
|
530
|
-
raise BuddyError('CONTINUATION_REQUIRED', '访谈还未完成:请在本轮给出下一条有效问题或确切内容确认,不能只回复“已保存”。若需要后台整理,先 draft_publish,再继续完成同一轮。')
|
|
531
|
-
state['deliveries'][delivery_id] = result
|
|
532
|
-
state['currentDeliveryId'] = delivery_id
|
|
533
|
-
if question or ids or design_question:
|
|
534
|
-
state['questionDeliveryId'] = delivery_id
|
|
535
|
-
elif explanation:
|
|
536
|
-
result['resumeDeliveryId'] = state.get('questionDeliveryId')
|
|
537
|
-
return result
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
def available(state):
|
|
541
|
-
import widgets
|
|
542
|
-
questions = []
|
|
543
|
-
for target_id in catalog()['cards']:
|
|
544
|
-
try:
|
|
545
|
-
askable(state, target_id)
|
|
546
|
-
questions.append(target_id)
|
|
547
|
-
except BuddyError:
|
|
548
|
-
pass
|
|
549
|
-
candidates = [a['id'] for a in state['artifacts'].values()
|
|
550
|
-
if (a['stage'] == state['stage'] or widgets.enabled(state) and a['kind'] in widgets.KINDS)
|
|
551
|
-
and confirmation_ready(state, a) and not any(confirmed(state, a['id'], d) for d in ['confirmed', 'accepted', 'rejected'])]
|
|
552
|
-
return {'questionTargets': questions, 'confirmationObjects': candidates, 'canDraftBooklet': can_draft(state, state['stage']), 'designPhase': widgets.phase(state)}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
def context(workspace, state):
|
|
556
|
-
import interview
|
|
557
|
-
import widgets
|
|
558
|
-
return {'workspace': str(Path(workspace).resolve()), 'revision': state['revision'], 'stage': state['stage'], 'paused': state['paused'], 'pendingTurnId': state.get('pendingTurnId'), 'catalog': catalog(), 'gates': gates(state, state['stage']), 'continuation': available(state), 'widgetDesign': widgets.projection(state, workspace), 'interviewGuidance': interview.guidance(state), 'state': state, 'contract': str(ROOT / 'references' / 'host-guide.md'), 'note': '宿主负责真实语义判断,runtime 仅校验结构、证据版本、状态与确认关系。每轮先保存原话,再整理、保存、展示并登记实际展示;不能替用户确认。'}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
def validate_initial_context(value):
|
|
562
|
-
require(isinstance(value, dict) and value.get('source') in ('platform_meta', 'conversation'), 'INITIAL_CONTEXT_SCHEMA', '初始背景需要 source=platform_meta 或 conversation。')
|
|
563
|
-
require(not set(value) - {'source', 'name', 'tagline', 'intro'}, 'INITIAL_CONTEXT_SCHEMA', '初始背景仅支持 source、name、tagline、intro,不接受凭据或采访状态。')
|
|
564
|
-
fields = [value[key] for key in ('name', 'tagline', 'intro') if key in value]
|
|
565
|
-
require(fields and all(isinstance(v, str) and len(v) <= 10000 for v in fields) and any(v.strip() for v in fields), 'INITIAL_CONTEXT_SCHEMA', '初始背景至少包含一项非空文本,每项不超过10000字。')
|
|
566
|
-
return copy.deepcopy(value)
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
def interview_started(state):
|
|
570
|
-
return bool(state['inputs'] or state['turns'] or state['artifacts'] or state['confirmations']
|
|
571
|
-
or state['sources'] or state['drafts'] or state['paused'])
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def needs_opening(state):
|
|
575
|
-
return bool(state.get('initialContext') and not interview_started(state)
|
|
576
|
-
and 'delivery_context_opening' not in state['deliveries'])
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def open_workspace(creation_key=None, workspace=None, initial_context=None):
|
|
580
|
-
if initial_context is not None:
|
|
581
|
-
initial_context = validate_initial_context(initial_context)
|
|
582
|
-
if workspace is None:
|
|
583
|
-
require(text(creation_key), 'CREATION_KEY_REQUIRED', '宿主需为本次创作生成稳定 creation-key;重试沿用同一个值,不需要用户提供 buddyid。')
|
|
584
|
-
if workspace:
|
|
585
|
-
base = Path(workspace).expanduser().resolve()
|
|
586
|
-
else:
|
|
587
|
-
directory_name = 'buddy-' + digest(creation_key)[:16]
|
|
588
|
-
base = (Path.cwd() / 'buddies' / directory_name).resolve()
|
|
589
|
-
previous = (Path.cwd() / 'buddies-no-node' / directory_name).resolve()
|
|
590
|
-
if not (base / 'state.json').exists() and (previous / 'state.json').is_file():
|
|
591
|
-
existing = load(previous)
|
|
592
|
-
require(existing.get('creationKeyHash') == digest(creation_key), 'CREATION_KEY_COLLISION', '旧项目的创建标识不匹配,请明确指定 workspace。')
|
|
593
|
-
base = previous
|
|
594
|
-
require(ROOT != base and ROOT not in base.parents, 'WORKSPACE_LOCATION', '项目资料应保存在 Skill 安装目录之外。')
|
|
595
|
-
if not (base / 'state.json').exists():
|
|
596
|
-
require(text(creation_key), 'CREATION_KEY_REQUIRED', '创建新的 Buddy 项目需要稳定 creation-key;已有项目只需 workspace。')
|
|
597
|
-
with workspace_lock(base, create=text(creation_key)):
|
|
598
|
-
changed = False
|
|
599
|
-
if (base / 'state.json').exists():
|
|
600
|
-
state = load(base)
|
|
601
|
-
if workspace is None:
|
|
602
|
-
require(state.get('creationKeyHash') == digest(creation_key), 'CREATION_KEY_COLLISION', '创建标识不匹配,请换一个新标识。')
|
|
603
|
-
else:
|
|
604
|
-
require(text(creation_key), 'CREATION_KEY_REQUIRED', '创建新的 Buddy 项目需要稳定 creation-key;已有项目只需 workspace。')
|
|
605
|
-
unexpected = [p.name for p in base.iterdir() if p.name != '.buddy.lock']
|
|
606
|
-
require(not unexpected, 'WORKSPACE_NOT_EMPTY', '新项目需要空目录;不能覆盖已有资料或 Node 版项目。', {'files': unexpected[:10]})
|
|
607
|
-
cards = catalog()['cards']
|
|
608
|
-
state = {'schemaVersion': SCHEMA, 'buddyId': base.name, 'workspaceId': 'workspace_' + digest(str(base))[:24], 'creationKeyHash': digest(creation_key), 'revision': '', 'stage': 'definition', 'paused': False, 'inputs': {}, 'turns': {}, 'pendingTurnId': None, 'deliveries': {}, 'presentations': [], 'currentDeliveryId': None, 'targets': {key: {'id': key, 'status': 'unstarted', 'summary': '', 'gaps': [], 'evidence': [], 'answerInputIds': []} for key in cards}, 'artifacts': {}, 'confirmations': [], 'sources': {}, 'sourcePlan': {'sourceIds': [], 'requiredKinds': [], 'discoveryClosed': False}, 'serviceMode': None, 'serviceModelExplained': False, 'drafts': [], 'updatedAt': now()}
|
|
609
|
-
state['widgetDesignVersion'] = 1
|
|
610
|
-
if initial_context is None:
|
|
611
|
-
opening = (ROOT / 'references' / 'opening.md').read_text(encoding='utf-8').strip()
|
|
612
|
-
delivery_record(state, {'text': opening, 'question': {'targetId': 'D01'}, 'mode': 'opening'}, 'delivery_opening', opening=True)
|
|
613
|
-
changed = True
|
|
614
|
-
if initial_context is not None:
|
|
615
|
-
if 'initialContext' in state:
|
|
616
|
-
require(state['initialContext'] == initial_context, 'INITIAL_CONTEXT_CONFLICT', '初始背景已保存。重试沿用原文件,新的想法通过真实对话修订,不覆盖原始背景。')
|
|
617
|
-
else:
|
|
618
|
-
state['initialContext'] = initial_context
|
|
619
|
-
changed = True
|
|
620
|
-
if changed:
|
|
621
|
-
save(base, state)
|
|
622
|
-
return base, state
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
def active_turn(state, turn_id):
|
|
626
|
-
turn = state['turns'].get(turn_id)
|
|
627
|
-
require(turn, 'TURN_NOT_FOUND', '未找到这一轮,先 turn_begin 保存原话。')
|
|
628
|
-
require(turn['status'] == 'working' and state.get('pendingTurnId') == turn_id, 'TURN_NOT_ACTIVE', '这一轮不再处于处理中;不要创建重复输入,请读取已有结果。')
|
|
629
|
-
return turn
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def finish_turn(state, payload, workspace=None):
|
|
633
|
-
import interview
|
|
634
|
-
import widgets
|
|
635
|
-
import widget_render
|
|
636
|
-
turn = state['turns'].get(payload.get('turnId'))
|
|
637
|
-
require(turn, 'TURN_NOT_FOUND', '未找到这一轮。')
|
|
638
|
-
payload_hash = digest({k: v for k, v in payload.items() if k != 'operation'})
|
|
639
|
-
if turn['status'] == 'finished':
|
|
640
|
-
require(turn.get('finishHash') == payload_hash, 'IDEMPOTENCY_CONFLICT', '这一轮已用不同内容提交,不能重写;用户有新修改时开始新一轮。')
|
|
641
|
-
return state['deliveries'][turn['result']['deliveryId']], False
|
|
642
|
-
active_turn(state, turn['id'])
|
|
643
|
-
intent = payload.get('intent', 'answer')
|
|
644
|
-
require(intent in {'answer', 'revision', 'confirmation', 'explanation', 'pause', 'resume'}, 'INTENT_SCHEMA', 'intent 使用 answer/revision/confirmation/explanation/pause/resume。')
|
|
645
|
-
patch = payload.get('patch', {})
|
|
646
|
-
require(isinstance(patch, dict) and not set(patch) - {'targets', 'artifacts', 'confirmations', 'sourcePlan', 'serviceMode', 'serviceModelExplained', 'paused', 'interview', 'enableWidgetDesign'}, 'PATCH_SCHEMA', 'patch 包含未支持字段。')
|
|
647
|
-
inp = state['inputs'][turn['inputId']]
|
|
648
|
-
if intent == 'explanation':
|
|
649
|
-
require(not patch, 'EXPLANATION_ONLY', '纯答疑不修改采访目标、产物或确认;需要修订时使用 revision。')
|
|
650
|
-
if 'enableWidgetDesign' in patch:
|
|
651
|
-
request = patch['enableWidgetDesign']
|
|
652
|
-
require(isinstance(request, dict) and set(request) == {'evidence'}, 'WIDGET_OPT_IN', '旧作品追加设计需引用本轮开发者的明确请求。')
|
|
653
|
-
evidence(state, request['evidence'], inp['id'])
|
|
654
|
-
state['widgetDesignVersion'] = 1
|
|
655
|
-
replied = state['deliveries'].get(inp.get('replyToDeliveryId'), {})
|
|
656
|
-
target_id = replied.get('question', {}).get('targetId')
|
|
657
|
-
interview_patch = patch.get('interview', {})
|
|
658
|
-
# Seed legacy counters before appending the current answer. Reading/opening a
|
|
659
|
-
# 1.0.0 project alone does not rewrite it or reopen settled targets.
|
|
660
|
-
if target_id and interview.tracked(target_id):
|
|
661
|
-
interview.ensure(state, target_id)
|
|
662
|
-
for changed_target in interview.prepare(state, interview_patch, inp, intent):
|
|
663
|
-
invalidate(state, book_ids(catalog()['cards'][changed_target]['stage']), turn['id'])
|
|
664
|
-
if intent == 'answer' and target_id and inp['id'] not in state['targets'][target_id]['answerInputIds']:
|
|
665
|
-
state['targets'][target_id]['answerInputIds'].append(inp['id'])
|
|
666
|
-
if interview.tracked(target_id):
|
|
667
|
-
interview.assess(state, target_id, replied['question'].get('gapId'), interview_patch.get('assessment'), inp)
|
|
668
|
-
else:
|
|
669
|
-
require('assessment' not in interview_patch, 'ASSESSMENT_SCOPE', '仅实际采访回答提交 assessment;解释、修订、确认和恢复不计次。')
|
|
670
|
-
for proposal in patch.get('targets', []):
|
|
671
|
-
require(isinstance(proposal, dict) and set(proposal) == {'id', 'status', 'summary', 'gaps', 'evidence'}, 'TARGET_SCHEMA', 'targets 每项使用 id/status/summary/gaps/evidence。')
|
|
672
|
-
key = proposal['id']
|
|
673
|
-
require(key in state['targets'], 'UNKNOWN_TARGET', '未知采访目标。')
|
|
674
|
-
require(catalog()['cards'][key]['stage'] == state['stage'] or catalog()['stages'].index(catalog()['cards'][key]['stage']) < catalog()['stages'].index(state['stage']), 'STAGE_SCOPE', '后续阶段信息可保留在原话中,不能提前完成采访。')
|
|
675
|
-
require(proposal['status'] in CLOSED | {'unstarted', 'exploring'} and isinstance(proposal['summary'], str) and text_list(proposal['gaps'], True), 'TARGET_SCHEMA', 'status 使用 unstarted/exploring/sufficient/uncertain/skipped/exhausted。')
|
|
676
|
-
evidence(state, proposal['evidence'], inp['id'] if proposal['status'] in {'skipped', 'uncertain'} else None)
|
|
677
|
-
if proposal['status'] == 'sufficient':
|
|
678
|
-
require(proposal['evidence'], 'EVIDENCE_REQUIRED', '充分性判断必须有真实依据。')
|
|
679
|
-
old = state['targets'][key]
|
|
680
|
-
require(not (old['status'] in CLOSED and proposal['status'] in {'unstarted', 'exploring'}),
|
|
681
|
-
'REOPEN_REQUIRED', '已收口目标不能直接重设为进行中;只有明确续谈请求可使用 interview.reopen。')
|
|
682
|
-
if any(old.get(field) != proposal.get(field) for field in ('summary', 'status', 'gaps')):
|
|
683
|
-
invalidate(state, book_ids(catalog()['cards'][key]['stage']), turn['id'])
|
|
684
|
-
state['targets'][key].update(copy.deepcopy(proposal))
|
|
685
|
-
before_settle = {key: (value['status'], list(value['gaps'])) for key, value in state['targets'].items()}
|
|
686
|
-
interview.settle(state)
|
|
687
|
-
for key, target in state['targets'].items():
|
|
688
|
-
if before_settle[key] != (target['status'], target['gaps']):
|
|
689
|
-
invalidate(state, book_ids(catalog()['cards'][key]['stage']), turn['id'])
|
|
690
|
-
if 'sourcePlan' in patch:
|
|
691
|
-
plan = patch['sourcePlan']
|
|
692
|
-
require(isinstance(plan, dict) and set(plan) == {'sourceIds', 'requiredKinds', 'discoveryClosed'} and isinstance(plan['sourceIds'], list) and isinstance(plan['requiredKinds'], list) and isinstance(plan['discoveryClosed'], bool), 'SOURCE_PLAN_SCHEMA', 'sourcePlan 使用 sourceIds/requiredKinds/discoveryClosed。')
|
|
693
|
-
require(all(k in state['sources'] for k in plan['sourceIds']) and set(plan['requiredKinds']) <= SOURCE_KINDS, 'SOURCE_PLAN_UNKNOWN', '来源计划必须使用真实导入 ID 和支持的类型。')
|
|
694
|
-
if state['sourcePlan'] != plan:
|
|
695
|
-
invalidate(state, book_ids('knowledge'), turn['id'])
|
|
696
|
-
state['sourcePlan'] = copy.deepcopy(plan)
|
|
697
|
-
if 'serviceModelExplained' in patch:
|
|
698
|
-
require(isinstance(patch['serviceModelExplained'], bool), 'SERVICE_INTRO', 'serviceModelExplained 使用布尔值。')
|
|
699
|
-
state['serviceModelExplained'] = patch['serviceModelExplained']
|
|
700
|
-
if 'serviceMode' in patch:
|
|
701
|
-
require(state['serviceModelExplained'] and patch['serviceMode'] in {'smart', 'guided'}, 'SERVICE_MODE', '先真实解释三阶段并由用户选择 smart/guided。')
|
|
702
|
-
state['serviceMode'] = patch['serviceMode']
|
|
703
|
-
if 'paused' in patch:
|
|
704
|
-
require(isinstance(patch['paused'], bool), 'PAUSED_SCHEMA', 'paused 使用布尔值,仅按用户明确暂停/恢复意愿设置。')
|
|
705
|
-
state['paused'] = patch['paused']
|
|
706
|
-
proposals = patch.get('artifacts', [])
|
|
707
|
-
for record in patch.get('confirmations', []):
|
|
708
|
-
artifact = state['artifacts'].get(record.get('objectId'), {})
|
|
709
|
-
if artifact.get('kind') in {'widget-prototype', 'widget-acceptance'}:
|
|
710
|
-
prototype = artifact if artifact['kind'] == 'widget-prototype' else state['artifacts'].get(widgets.object_id(artifact['data']['widgetId']))
|
|
711
|
-
require(workspace, 'WIDGET_ASSETS_REQUIRED', '通过工作目录调用以校验实际原型资源。')
|
|
712
|
-
widget_render.require_assets(workspace, state, prototype)
|
|
713
|
-
record_confirmations(state, turn, patch.get('confirmations', []), {a.get('id') for a in proposals})
|
|
714
|
-
put_artifacts(state, proposals, turn['id'])
|
|
715
|
-
for proposal in proposals:
|
|
716
|
-
if proposal['kind'] == 'widget-prototype' and workspace:
|
|
717
|
-
widget_render.render(workspace, state, state['artifacts'][proposal['id']])
|
|
718
|
-
for key in (payload.get('delivery') or {}).get('confirmationObjectIds', []):
|
|
719
|
-
artifact = state['artifacts'].get(key, {})
|
|
720
|
-
if artifact.get('kind') in {'widget-prototype', 'widget-acceptance'}:
|
|
721
|
-
prototype = artifact if artifact['kind'] == 'widget-prototype' else state['artifacts'].get(widgets.object_id(artifact['data']['widgetId']))
|
|
722
|
-
require(workspace, 'WIDGET_ASSETS_REQUIRED', '先生成实际原型,再展示确认。')
|
|
723
|
-
widget_render.require_assets(workspace, state, prototype)
|
|
724
|
-
# Faithful base-case capture is a user answer, not invented consent to a new proposal.
|
|
725
|
-
for proposal in proposals:
|
|
726
|
-
if proposal['kind'] == 'scenario' and proposal['id'].startswith('scenario.M') and proposal.get('data', {}).get('capture') == 'faithful_user_answer':
|
|
727
|
-
require(replied.get('question', {}).get('targetId') == proposal['id'][9:] and not proposal['unresolved'], 'SCENARIO_ANSWER_SCOPE', 'faithful_user_answer 只能忠实记录用户刚回答且完整的对应基础场景。')
|
|
728
|
-
evidence(state, proposal['evidence'], inp['id'])
|
|
729
|
-
artifact = state['artifacts'][proposal['id']]
|
|
730
|
-
require_confirmation_ready(state, [artifact])
|
|
731
|
-
state['confirmations'].append({'id': 'confirmation_' + digest([inp['id'], artifact['hash']])[:24], 'objectId': artifact['id'], 'hash': artifact['hash'], 'inputId': inp['id'], 'deliveryId': replied['id'], 'decision': 'confirmed', 'evidence': proposal['evidence']})
|
|
732
|
-
state['stage'] = choose_stage(state)
|
|
733
|
-
result = delivery_record(state, payload.get('delivery'), 'delivery_' + digest(turn['id'])[:24], inp['id'], explanation=intent == 'explanation')
|
|
734
|
-
turn.update(status='finished', updatedAt=now(), finishHash=payload_hash, result={'deliveryId': result['id']})
|
|
735
|
-
state['pendingTurnId'] = None
|
|
736
|
-
return result, True
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
def call(workspace, payload):
|
|
740
|
-
require(isinstance(payload, dict), 'REQUEST_SCHEMA', '请求必须为 JSON 对象。')
|
|
741
|
-
operation = payload.get('operation')
|
|
742
|
-
require(operation in {'opening_set', 'turn_begin', 'turn_finish', 'turn_continue', 'presentation_record', 'draft_publish', 'source_import', 'widget_render', 'finalize', 'snapshot'}, 'OPERATION_UNKNOWN', '未知 operation。')
|
|
743
|
-
workspace = Path(workspace).expanduser().resolve()
|
|
744
|
-
completion_needed = False
|
|
745
|
-
with workspace_lock(workspace):
|
|
746
|
-
state = load(workspace)
|
|
747
|
-
changed = False
|
|
748
|
-
result = {}
|
|
749
|
-
if operation == 'opening_set':
|
|
750
|
-
proposal = payload.get('delivery')
|
|
751
|
-
require(state.get('initialContext') and not interview_started(state), 'OPENING_NOT_ALLOWED', '仅在已有背景且尚未开始采访时准备开场;已有回答按原进度继续。')
|
|
752
|
-
require(isinstance(proposal, dict) and set(proposal) == {'text', 'question', 'mode'}
|
|
753
|
-
and proposal.get('mode') == 'opening', 'OPENING_SCHEMA', '开场只包含 text、question 和 mode=opening,不能确认产物。')
|
|
754
|
-
existing = state['deliveries'].get('delivery_context_opening')
|
|
755
|
-
if existing:
|
|
756
|
-
def with_default_gap(question):
|
|
757
|
-
if not isinstance(question, dict):
|
|
758
|
-
return None
|
|
759
|
-
return {'gapId': str(question.get('targetId')) + '.initial', **question}
|
|
760
|
-
require(existing['text'] == proposal['text'] and existing['mode'] == proposal['mode']
|
|
761
|
-
and with_default_gap(existing.get('question')) == with_default_gap(proposal['question']),
|
|
762
|
-
'OPENING_CONFLICT', '开场已经保存;重试沿用原内容,后续修订通过真实对话处理。')
|
|
763
|
-
result = {'delivery': existing, 'directive': 'present_delivery'}
|
|
764
|
-
else:
|
|
765
|
-
# Use upstream binding and gap validation, without punctuation/length heuristics.
|
|
766
|
-
delivery = delivery_record(state, proposal, 'delivery_context_opening')
|
|
767
|
-
result = {'delivery': delivery, 'directive': 'present_delivery'}
|
|
768
|
-
changed = True
|
|
769
|
-
elif operation == 'turn_begin':
|
|
770
|
-
raw, client_key = payload.get('raw'), payload.get('clientKey')
|
|
771
|
-
require(isinstance(raw, str) and raw.strip() and text(client_key), 'INPUT_REQUIRED', 'turn_begin 需要原样 raw 和本轮稳定 clientKey。')
|
|
772
|
-
turn_id = 'turn_' + digest(client_key)[:24]
|
|
773
|
-
existing = state['turns'].get(turn_id)
|
|
774
|
-
if existing:
|
|
775
|
-
require(state['inputs'][existing['inputId']]['raw'] == raw, 'IDEMPOTENCY_CONFLICT', '同一 clientKey 对应不同原话;新输入必须使用新 clientKey。')
|
|
776
|
-
result = {'turn': existing, 'input': state['inputs'][existing['inputId']], 'directive': 'deliver_saved_result' if existing['status'] == 'finished' else 'continue_turn'}
|
|
777
|
-
if existing['status'] == 'finished':
|
|
778
|
-
result['delivery'] = state['deliveries'][existing['result']['deliveryId']]
|
|
779
|
-
else:
|
|
780
|
-
require(not state.get('pendingTurnId'), 'TURN_IN_PROGRESS', '上一轮还没提交;先 turn_continue 恢复,不要丢弃原话或重复创建。', {'turnId': state.get('pendingTurnId')})
|
|
781
|
-
presented = payload.get('presentedDeliveryId')
|
|
782
|
-
if presented:
|
|
783
|
-
require(presented == state.get('currentDeliveryId') and presented in state['deliveries'], 'PRESENTATION_STALE', '只能登记实际已展示的当前回复。')
|
|
784
|
-
if presented not in state['presentations']:
|
|
785
|
-
state['presentations'].append(presented)
|
|
786
|
-
question_id = state.get('questionDeliveryId') or state.get('currentDeliveryId')
|
|
787
|
-
reply_id = question_id if question_id in state['presentations'] else None
|
|
788
|
-
inp = {'id': 'input_' + digest(client_key)[:24], 'raw': raw, 'hash': digest(raw), 'createdAt': now(), 'replyToDeliveryId': reply_id}
|
|
789
|
-
turn = {'id': turn_id, 'inputId': inp['id'], 'baseRevision': state['revision'], 'status': 'working', 'createdAt': now(), 'updatedAt': now()}
|
|
790
|
-
state['inputs'][inp['id']], state['turns'][turn_id], state['pendingTurnId'] = inp, turn, turn_id
|
|
791
|
-
atomic_json(workspace / 'inputs' / (inp['id'] + '.json'), inp)
|
|
792
|
-
result = {'turn': turn, 'input': inp, 'directive': 'prepare_turn'}
|
|
793
|
-
changed = True
|
|
794
|
-
elif operation == 'turn_finish':
|
|
795
|
-
delivery, changed = finish_turn(state, payload, workspace)
|
|
796
|
-
result = {'delivery': delivery, 'turn': state['turns'][payload['turnId']], 'directive': 'present_delivery'}
|
|
797
|
-
completion_needed = all(book_confirmed(state, stage) for stage in catalog()['stages'])
|
|
798
|
-
elif operation == 'turn_continue':
|
|
799
|
-
turn_id = payload.get('turnId') or state.get('pendingTurnId')
|
|
800
|
-
turn = state['turns'].get(turn_id)
|
|
801
|
-
require(turn, 'TURN_NOT_FOUND', '没有待恢复的轮次。使用当前 delivery 继续访谈;新原话使用 turn_begin。')
|
|
802
|
-
result = {'turn': turn, 'input': state['inputs'][turn['inputId']], 'directive': 'deliver_saved_result' if turn['status'] == 'finished' else 'continue_turn'}
|
|
803
|
-
if turn['status'] == 'finished':
|
|
804
|
-
result['delivery'] = state['deliveries'][turn['result']['deliveryId']]
|
|
805
|
-
elif operation == 'presentation_record':
|
|
806
|
-
key = payload.get('deliveryId')
|
|
807
|
-
require(key in state['deliveries'] and key == state.get('currentDeliveryId'), 'PRESENTATION_STALE', '只能登记已实际展示的当前 delivery。')
|
|
808
|
-
if key not in state['presentations']:
|
|
809
|
-
state['presentations'].append(key)
|
|
810
|
-
changed = True
|
|
811
|
-
result = {'deliveryId': key, 'presented': True}
|
|
812
|
-
elif operation == 'draft_publish':
|
|
813
|
-
turn = active_turn(state, payload.get('turnId'))
|
|
814
|
-
operation_id = payload.get('operationId')
|
|
815
|
-
require(text(operation_id), 'OPERATION_ID_REQUIRED', 'draft_publish 需要稳定 operationId,以便安全重试。')
|
|
816
|
-
prior = next((d for d in state['drafts'] if d['operationId'] == operation_id), None)
|
|
817
|
-
payload_hash = digest(payload)
|
|
818
|
-
if prior:
|
|
819
|
-
require(prior['payloadHash'] == payload_hash, 'IDEMPOTENCY_CONFLICT', '同一草稿操作编号已用于不同内容。')
|
|
820
|
-
result = prior
|
|
821
|
-
else:
|
|
822
|
-
draft_state = copy.deepcopy(state)
|
|
823
|
-
changed_ids = put_artifacts(draft_state, payload.get('artifacts', []), operation_id, draft=True)
|
|
824
|
-
rendered = '\n\n'.join('## ' + item['title'] + '\n\n' + item['markdown'] for item in payload.get('artifacts', []))
|
|
825
|
-
require(rendered.strip(), 'DRAFT_EMPTY', '请提供实际公开草稿。')
|
|
826
|
-
result = {'id': 'draft_' + digest(operation_id)[:24], 'operationId': operation_id, 'payloadHash': payload_hash, 'turnId': turn['id'], 'artifactIds': changed_ids, 'createdAt': now(), 'updatedAt': now(), 'sequence': len(state['drafts']) + 1, 'title': '正在整理的内容', 'markdown': rendered, 'hash': digest(rendered)}
|
|
827
|
-
state['drafts'].append(result)
|
|
828
|
-
changed = True
|
|
829
|
-
elif operation == 'source_import':
|
|
830
|
-
import sources
|
|
831
|
-
manifest = sources.import_source(workspace, state, payload)
|
|
832
|
-
old = state['sources'].get(manifest['id'])
|
|
833
|
-
state['sources'][manifest['id']] = manifest
|
|
834
|
-
changed = old != manifest
|
|
835
|
-
result = {'source': manifest, 'directive': 'source_ready' if manifest.get('status') == 'ready' else 'source_action_required'}
|
|
836
|
-
elif operation == 'widget_render':
|
|
837
|
-
import widget_render
|
|
838
|
-
import widgets
|
|
839
|
-
artifact = state['artifacts'].get(payload.get('objectId'), {})
|
|
840
|
-
require(artifact.get('kind') == 'widget-prototype' and not widgets.blockers(state, artifact), 'WIDGET_RENDER_SCOPE', '仅重新生成当前清单的有效原型,不改动确认记录。')
|
|
841
|
-
widget_render.render(workspace, state, artifact)
|
|
842
|
-
result = {'assets': widget_render.asset_status(workspace, artifact, state)}
|
|
843
|
-
elif operation == 'finalize':
|
|
844
|
-
require(all(book_confirmed(state, stage) for stage in catalog()['stages']), 'BOOKLETS_UNCONFIRMED', '四册还未全部确认,暂不能完成交付。')
|
|
845
|
-
import widgets
|
|
846
|
-
require(widgets.ready(state), 'WIDGET_DESIGN_UNCONFIRMED', '小挂件清单、原型或设计验收尚未确认。', {'phase': widgets.phase(state)})
|
|
847
|
-
completion_needed = True
|
|
848
|
-
else:
|
|
849
|
-
import preview
|
|
850
|
-
result = {'snapshot': preview.snapshot(workspace, state)}
|
|
851
|
-
if changed:
|
|
852
|
-
save(workspace, state)
|
|
853
|
-
completion_needed = completion_needed or (not state.get('pendingTurnId') and all(book_confirmed(state, s) for s in catalog()['stages']))
|
|
854
|
-
result.update(workspace=str(workspace), revision=state['revision'])
|
|
855
|
-
if operation not in {'snapshot', 'presentation_record'}:
|
|
856
|
-
result['context'] = context(workspace, state)
|
|
857
|
-
if completion_needed:
|
|
858
|
-
try:
|
|
859
|
-
import completion
|
|
860
|
-
result['completion'] = completion.finalize(workspace, state)
|
|
861
|
-
except Exception as error:
|
|
862
|
-
result['completion'] = {'status': 'failed', 'message': str(error), 'recovery': '用户原话和确认已保存;用 finalize 重试交付生成。'}
|
|
863
|
-
import completion
|
|
864
|
-
completion.return_to_caller(workspace, state, result)
|
|
865
|
-
return result
|