dsh-lite-memory 0.16.4 → 0.18.1
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 +10 -10
- package/TRIGGERS.md +14 -13
- package/anchor.js +259 -24
- package/client.js +1334 -215
- package/docs/BENCH.md +10 -8
- package/docs/CONVENTIONS.md +143 -0
- package/docs/RELEASE-0.17.0.md +8 -0
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- package/index.js +395 -105
- package/learning.js +456 -48
- package/package.json +9 -3
- package/state.js +119 -7
- package/store.js +627 -75
- package/test/anchor.test.mjs +240 -0
- package/test/client.test.mjs +216 -6
- package/test/contract.test.mjs +20 -9
- package/test/crossing.test.mjs +22 -1
- package/test/evolution-read.test.mjs +23 -0
- package/test/ha.test.mjs +136 -0
- package/test/learning.test.mjs +483 -1
- package/test/palace.test.mjs +102 -0
- package/test/recall.test.mjs +13 -0
- package/test/record.test.mjs +130 -0
- package/test/runtime-context.test.mjs +186 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +121 -2
- package/test/store.test.mjs +454 -15
- package/view.js +71 -3
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
|
|
|
16
16
|
| 场景 | 行为 |
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| **L0** 你明确要求「记住这个」 | 立即写盘,一条确认行 |
|
|
19
|
-
| **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · suggested "X"
|
|
19
|
+
| **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · suggested "X"` + 一个「保留」按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
|
|
20
20
|
| **L2** 常识、一次性参数、凭据的值 | 永不写入 |
|
|
21
21
|
|
|
22
22
|
这些行只属于产生它的那一轮:每轮按那轮自己的工具结果重建,重进会话还能看见,别的轮不会混进来。再记一次时插件自己判是更新还是新增:同层、同前置条件下,标题(或这条用过的旧标题)完全一致就更新原条目,换过说法而标题变了的旧标题记成别名以后也认得。像但不同名的不写:先在结果里回「相近:X」和它的 id,确认要更新就带那个 id 再存,确认是另一条才加 `resolution="distinct"`。更新时没给的字段保持原值,正文整段替换——旧正文只作内部撤回依据,不展示、不参与检索;前置条件不同的同一主题是两条。
|
|
@@ -27,12 +27,12 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
|
|
|
27
27
|
|
|
28
28
|
触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
|
|
31
31
|
|
|
32
32
|
| Case | Behaviour |
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| **L0** you ask it to remember | written immediately, one confirmation line |
|
|
35
|
-
| **L1** it spots something worth keeping | one muted line `memory · suggested "X" · sug_x` with a single
|
|
35
|
+
| **L1** it spots something worth keeping | one muted line `memory · suggested "X" · sug_x` with a single 「保留」 button — readable before you click, nothing written unless you click, at most once per two days |
|
|
36
36
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
37
37
|
|
|
38
38
|
Those lines belong to the turn that made them: each turn is rebuilt from its own tool results, so reopening the session shows them again and no other turn leaks in. Saving again updates an entry only when the title (or one of its earlier titles) matches exactly under the same precondition. A merely similar title writes nothing first: the result reports the closest entry and its id, so the caller either updates that id or re-saves with `resolution="distinct"`. An update keeps every field it was not given and replaces the body wholesale; the previous body is kept only for internal undo and never shown or searched. The same subject under different preconditions stays two entries.
|
|
@@ -43,9 +43,9 @@ Before writing, distill: the title is the conclusion and the body keeps only the
|
|
|
43
43
|
|
|
44
44
|
## 点一下就存 / One click, no turn
|
|
45
45
|
|
|
46
|
-
`
|
|
46
|
+
「保留」是同源直写:不产生消息、不动输入框、不打断当前回复。点完按钮消失,行变成 `memory · kept`。真写过的行尾才有 `✕`,按 id 撤销:改过的条目还原成上一版(显示 `restored`),刚建的条目移入归档(显示 `archived`);条目后来又改过就停下并说明已经变过,不覆盖后来的修改。没变、需要确认、保存失败的行没有撤销。
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
The 「保留」 button writes straight to disk from the page: no message, no extra model turn, the input box untouched. It disappears and the row becomes `memory · kept` — that is the whole interaction. Only a row that really changed gets the `✕`, and it undoes by id: an edited entry goes back to its previous body (`restored`), a freshly created one moves to the archive (`archived`); if the entry changed again it stops and says so instead of overwriting. Noop, needs-confirmation and failed saves get no undo.
|
|
49
49
|
|
|
50
50
|
## 每轮引用条 / Per-turn references
|
|
51
51
|
|
|
@@ -55,13 +55,13 @@ Each completed turn keeps a few lines under 「已编辑 N 个文件」, built f
|
|
|
55
55
|
|
|
56
56
|
## 记忆面板 / Memory panel
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
会话头的大脑图标打开居中的记忆面板。顶部是「启用记忆」「自进化」两个开关,右上角「显示设置」里另有「启用图谱」「启用记忆宫殿」两个只影响展示的开关,默认关闭,按项目记住;内容分「记忆」「自进化」两个页签。默认看当前项目,也可以切到其他项目或全局层;切过去只读,不回写、不迁移旧格式。
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
「记忆」页签里,搜索框右侧可以在「列表」「图谱」「宫殿」之间切换;列表、图谱或宫殿占满内容宽度,选中记忆后右侧才出现正文与归属。图谱按真实数据画,点记忆节点读正文,点标签或主题节点转成搜索,右上角可缩放。宫殿按类型分四间:习惯与规范、方法与经验、资料与入口、其他记录;一次只展开一间,每间每页 24 条,点展品只读正文。宫殿只决定怎么陈列,不改记忆、学习状态和额度。「自进化」页签只作用于当前项目,按「全部 / 候选 / 试用 / 生效 / 暂停」筛选,也能切列表与图谱;详情给出当前经验、适用范围、当前状态、学习依据和变化记录。候选可采纳、修改、放弃,试用可采纳、修改、暂停,生效可修改、暂停,暂停可恢复或查看记录。
|
|
61
61
|
|
|
62
|
-
The brain icon in the session header opens a centered memory panel.
|
|
62
|
+
The brain icon in the session header opens a centered memory panel. Memory and Self-evolution sit at the top, and Display settings at the top right add two presentation-only switches, Graph and Memory palace, both off by default and remembered per project. Two tabs, Memories and Self-evolution, hold the content. It starts on the current project and can switch to another project or the global layer; switched scopes are read-only and never migrate or create state.
|
|
63
63
|
|
|
64
|
-
On the Memories tab, the search row
|
|
64
|
+
On the Memories tab, the search row switches between list, graph and palace; either view fills the content width, and the body pane only appears after a memory is selected. The graph is drawn from real data: a memory node opens its body, a tag or topic hub becomes a search, and the top-right controls zoom it. The palace sorts memories into four rooms by kind: Habits and conventions, Methods and experience, References and entries, and Other notes; one room opens at a time, each page holds 24 items, and an exhibit only opens its body. The palace changes presentation only, never memory, learning state or quota. The Self-evolution tab acts on the current project only, filters by All / Candidate / Trial / Active / Paused, and also offers list and graph views. It shows the current rule, scope, state, evidence and change history. Candidates can be adopted, edited or discarded; trials can be adopted, edited or paused; active rules can be edited or paused; paused rules can be resumed or inspected.
|
|
65
65
|
|
|
66
66
|
- 关闭项目记忆后,这个项目不再使用任何记忆(包括全局层):自动注入、检索、读取、引用、保存全部停,服务端也拒绝记忆工具和面板修改动作。自进化同时关闭并禁用;重新打开记忆后自进化仍为关闭,要单独开启。
|
|
67
67
|
- 关闭只改权限,不删数据:候选、试用、生效、证据、整理额度和默认绑定都保留,计数照常显示。
|
|
@@ -119,7 +119,7 @@ Install from the DSH Plugin Manager (search `dsh-lite-memory`), or `npm pack` an
|
|
|
119
119
|
|
|
120
120
|
`memory_save` · `memory_suggest` · `memory_search` · `memory_read` · `memory_forget`
|
|
121
121
|
|
|
122
|
-
完整触发规则见 [TRIGGERS.md](TRIGGERS.md);性能与 UI 门禁的实测数字见 [docs/BENCH.md](docs/BENCH.md)。 / Full trigger rules: [TRIGGERS.md](TRIGGERS.md); measured performance and UI-gate evidence: [docs/BENCH.md](docs/BENCH.md).
|
|
122
|
+
完整触发规则见 [TRIGGERS.md](TRIGGERS.md),内部判定契约(准入顺序、状态码、写入/更新/失败语义)见 [docs/CONVENTIONS.md](docs/CONVENTIONS.md);性能与 UI 门禁的实测数字见 [docs/BENCH.md](docs/BENCH.md)。 / Full trigger rules: [TRIGGERS.md](TRIGGERS.md); internal decision contract: [docs/CONVENTIONS.md](docs/CONVENTIONS.md); measured performance and UI-gate evidence: [docs/BENCH.md](docs/BENCH.md).
|
|
123
123
|
|
|
124
124
|
## License
|
|
125
125
|
|
package/TRIGGERS.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# 触发与展示
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
写入分三档,档位决定「谁说了算」和「什么时候出现在对话里」;读取另有一条自动注入。
|
|
4
4
|
|
|
5
5
|
| 档 | 谁发起 | 什么时候 | 存不存 | 用户看到什么 |
|
|
6
6
|
| --- | --- | --- | --- | --- |
|
|
7
7
|
| L0 | 用户 | 说「记住 / 以后 / 别用 / 下次」这类话 | 直接存 | `memory · saved "X"` + `✕` |
|
|
8
|
-
| L1 | 模型 | 用户没说,但这经验跨会话有用 | 点了才存 | `memory ·
|
|
8
|
+
| L1 | 模型 | 用户没说,但这经验跨会话有用 | 点了才存 | `memory · suggested "X" · sug_x` + 「保留」按钮 |
|
|
9
9
|
| L2 | — | 常识、一次性参数、临时路径 | 不存 | 无 |
|
|
10
10
|
|
|
11
11
|
## 什么时候触发
|
|
@@ -16,24 +16,24 @@
|
|
|
16
16
|
- 写:用户明确要求;用户自己的话已经是长期规则(「以后都这样 / 别再 X」)时直接存,不先问;或用户对某条建议点了「保留」。
|
|
17
17
|
- 建议:一条经验能跨会话复用、又只存在于用户嘴里(偏好、惯例、踩过的坑),且同一项目 48 小时内没建议过。只出按钮,不写「要不要记」这种问句——问句要用户打字,按钮不要。
|
|
18
18
|
- 不写:`1+1=2` 这种常识、一次性任务参数、能从仓库直接读到的正文;凭据只记位置与调用方式。
|
|
19
|
-
-
|
|
19
|
+
- 自动命中:用户这句话与某条记忆的标题、旧标题、标签、触发词或摘要真有重合、且分数过线时,这一步的运行时上下文里多一段「已有记忆可能相关」——最多三条标题加 id,普通卡片与实验规则共用 3 条 / 1200 字节预算,正文仍要 `memory_read`。只有当前任务条件判为 `ok` 的条目能进;条件没确认或明确不符的既不进自动注入,也不进检索结果,检索只在全都被挡时说明「有相关的项目记忆,但适用条件还没确认 / 当前任务明确不适用」。没重合为空字符串,整条索引不进上下文,也不改 hits / lastUsed,更不落盘。同一句话只算一次;换句话重新算。
|
|
20
20
|
|
|
21
21
|
## 触发后长什么样
|
|
22
22
|
|
|
23
23
|
每次调用只占一行、灰色、12px:
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
· memory · 2
|
|
27
|
-
· memory ·
|
|
28
|
-
· memory · saved "插件市场条目字段"
|
|
29
|
-
· memory ·
|
|
26
|
+
· memory · 命中 2 条 · 引用 120 字
|
|
27
|
+
· memory · 没有命中
|
|
28
|
+
· memory · saved "插件市场条目字段" · mem_xxx
|
|
29
|
+
· memory · suggested "发布前先跑文案检查" · sug_xxx
|
|
30
30
|
· memory · skipped
|
|
31
|
-
· memory ·
|
|
31
|
+
· memory · archived "插件市场条目字段" · mem_xxx,可找回
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`
|
|
34
|
+
「保留」按钮只在建议行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话;按钮消失,行变成 `memory · kept`。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议。真写过的行尾才有 `✕`,带着写入时的版本撤销;条目后来又改过就停下并提示版本已变,不覆盖后来的修改。
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
这些行只属于产生它的那一轮:每轮按那轮自己的工具结果重建,重进会话还能看见,不会串到别的轮。行出现在调用本身的位置——一轮正在跑时,那就是对话的最下面,不会悬浮在输入框上方。
|
|
37
37
|
|
|
38
38
|
## 通用还是项目
|
|
39
39
|
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
|
|
46
46
|
检索一次查两层,带「[全局]」前缀的是跨项目那层;两层同名条目各存各的,不互相合并。归档、读正文按 id 走,全局条目在别的项目里也能归档。
|
|
47
47
|
|
|
48
|
+
内部判定细节(准入顺序、状态码、失败语义)见 [docs/CONVENTIONS.md](docs/CONVENTIONS.md)。
|
|
49
|
+
|
|
48
50
|
为什么是这三层:[Claude Code](https://code.claude.com/docs/en/memory) 分 user / project / local,[Codex 的 AGENTS.md](https://agents.md/) 分 `~/.codex` 与仓库根、子目录,都是「按作用域分层、就近优先」,不做语义去重。我们照这个骨架,多的一层是 `when`:同一主题在不同前提下必须能并存,否则合并就是拿一种情况覆盖另一种。[Graphiti](https://github.com/getzep/graphiti) 的做法是每条事实带有效期、冲突只让旧事实失效而不删除,我们收敛成「同键覆盖、旧要点保留」。
|
|
49
51
|
|
|
50
52
|
## 什么时候更新,什么时候新建
|
|
@@ -62,7 +64,6 @@
|
|
|
62
64
|
更新时没给的字段保持原值,给了才改(`when`、`kind`、`tags`、正文都一样);正文整段替换,不做章节合并。
|
|
63
65
|
|
|
64
66
|
- 旧正文原样进 `versions`,最多留两版,可据此回滚——`✕` 一次把标题、场景、标签和正文一起还原;版本对不上时只提示,不动数据。
|
|
65
|
-
- `versions` 是完整备份,不截断。
|
|
66
67
|
- 真要把一条记忆作废重写(不是补充),先 `memory_forget` 再 `memory_save`。
|
|
67
68
|
|
|
68
69
|
按钮那条路也一样:`memory_suggest` 遇到已有同名条目就不出按钮,回「已有「X」,这次不重复建议」;用户点了「保留」的,落到同一个判重函数上。候选里存着确认时的目标 id 与版次:那条记忆在你确认前改过,就重新建议一次,不会拿旧内容盖掉新内容。
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
## 频率与退让
|
|
71
72
|
|
|
72
73
|
- 建议:同一项目 48 小时一条、一次对话最多一条;同一主题存过就不再建议。
|
|
73
|
-
- 结果:搜索最多 4
|
|
74
|
+
- 结果:搜索最多 4 条,默认 3 条;每条一行摘要,全文只在 `memory_read` 里出现。
|
|
74
75
|
- 用户说「别记」→ `memory_forget` 归档,不删文件。
|
|
75
76
|
- 拒绝三次后仍反复建议,说明触发词太宽:收窄 `memory_suggest` 的描述,而不是加状态。
|
|
76
77
|
|
|
@@ -78,5 +79,5 @@
|
|
|
78
79
|
|
|
79
80
|
- 记忆只在工具结果里出现,从不进 system prompt,也从不伪造用户消息。
|
|
80
81
|
- 结果行永远是「记忆 · …」,模型能分清这是历史笔记,不是用户本轮要求。
|
|
81
|
-
-
|
|
82
|
+
- 摘要必须是名词短语;「你要…」「请…」这类指令式写法违反约定(工具描述里也会提醒),但写入校验不会替模型改。
|
|
82
83
|
- 存储按项目目录哈希隔离,别的项目看不到。
|
package/anchor.js
CHANGED
|
@@ -26,6 +26,28 @@ const OLD_WORK_ACTION = /(继续|接着|再改|改成|改一下|调整|优化|
|
|
|
26
26
|
/** 创作类与工程类任务的默认判断不同;同一主题从写口播切到排查代码,不能沿用旧栏目。 */
|
|
27
27
|
const CONTENT_INTENT = /(口播|拍摄|录制|剪辑|文案|封面|字幕|直播|视频|选题|脚本|一期|栏目)/;
|
|
28
28
|
const ENGINEERING_INTENT = /(代码|排查|故障|部署|调试|重构|接口|数据库|查询|报错|日志|服务|环境|依赖|测试|配置|修复)/;
|
|
29
|
+
/** 运行时任务判断只分工程/创作两类;两类词同时出现时工程证据优先。 */
|
|
30
|
+
const CODE_TASK_WORDS = /(源码|代码|插件|仓库|数据库|报错|日志|重构|修复|排查|测试|编译|依赖|bug)/i;
|
|
31
|
+
const CONTENT_TASK_WORDS = /(口播|选题|拍摄|剪辑|发布|封面|字幕)/;
|
|
32
|
+
/** 「口播功能/模块/页面」是代码对象;只有整句确有工程语境时才否定栏目绑定。 */
|
|
33
|
+
const CODE_OBJECT_AFTER = /^\s*(插件|源码|代码|仓库|报错|日志|接口|函数|依赖|测试|编译|功能|模块|组件|页面|流程|逻辑)/;
|
|
34
|
+
const CODE_OBJECT_NOUN = /(功能|模块|组件|页面|接口|流程|逻辑)/;
|
|
35
|
+
const CODE_CONTEXT_WORDS = /(bug|代码|修复|排查|故障|报错|调试|测试|编译|部署|仓库|函数|插件|源码|接口|实现|开发|重构|配置)/i;
|
|
36
|
+
/** 讲解/介绍后面的对象是话题,不是当前交付栏目。 */
|
|
37
|
+
const TOPIC_SERIES_BEFORE = /(讲解|介绍|聊聊|说说|分享|科普|解释|围绕|关于|主题是|主题为|话题是|话题为)[^,。;!?\n]{0,8}$/;
|
|
38
|
+
const TOPIC_AFTER = /(?:讲解|介绍|聊聊|说说|分享|科普|解释|围绕|关于|主题是|主题为|话题是|话题为)\s*([^,。;!?,.;!?\n]{2,24})/;
|
|
39
|
+
const TOPIC_OBJECT = /([^\s,。;!?,.;!?]{2,16}?(?:功能|模块|组件|页面|接口|流程|逻辑))/;
|
|
40
|
+
const TOPIC_SUBJECT = /^([^,。;!?\n]{2,20}?)(?:最近|现在|老是|总是|已经|出现|报|有|坏|异常|不对)/;
|
|
41
|
+
/** 明确把栏目放下、停掉,才算撤销;句末的「吧/啦/了」只是语气,不是撤销。 */
|
|
42
|
+
const REVOKE_SERIES_BEFORE = /(先不做|不做了|不再做|不做|不搞|不拍|不写|不剪|不发|先放着|先放|放一边|暂停|停掉|去掉|别做|不要做)[^,。;!?\n]{0,8}$/;
|
|
43
|
+
const REVOKE_SERIES_AFTER = /^(先不做|不做了|不搞了|不拍了|不写了|暂停|先放着|放一边|不要了|停掉)/;
|
|
44
|
+
const ACTION_RULES = [
|
|
45
|
+
['fix', /(修复|修好|排查|定位|解决|修正|调试|debug|bug|报错|故障)/i],
|
|
46
|
+
['review', /(审阅|审查|检查|评估|看看|复盘|review)/i],
|
|
47
|
+
['edit', /(改|调整|优化|润色|修改|重写|更新)/],
|
|
48
|
+
['create', /(做|制作|写|拍摄|录制|剪辑|生成|出一期|再做一期)/],
|
|
49
|
+
['explain', /(讲解|介绍|说明|分享|聊聊|说说|科普|解释)/],
|
|
50
|
+
];
|
|
29
51
|
/** 这些词描述的是旧稿的某个方面,不是新对象;不该因为它们出现就撤销旧范围。 */
|
|
30
52
|
const ASPECT_NOISE = /(节奏|速度|风格|语气|口吻|结构|长度|时长|篇幅|配色|字号|字体|标题|文案|措辞|逻辑|细节|开头|结尾|过渡|画面|镜头|音乐|音效|字幕|参数|格式|排版)/g;
|
|
31
53
|
/** 程度、指代和虚词组成的二字片段不是对象证据。 */
|
|
@@ -111,12 +133,137 @@ export function seriesChosenBy(query, series) {
|
|
|
111
133
|
if (at < 0) return false;
|
|
112
134
|
const before = value.slice(Math.max(0, at - 14), at);
|
|
113
135
|
const after = value.slice(at + name.length);
|
|
114
|
-
|
|
136
|
+
const asCodeObject = CODE_OBJECT_AFTER.test(after) && CODE_CONTEXT_WORDS.test(value);
|
|
137
|
+
if (!NEGATED_SERIES.test(before) && !MENTION_SERIES.test(before) && !PAUSED_BEFORE.test(before)
|
|
138
|
+
&& !PAUSED_SERIES.test(after) && !TOPIC_SERIES_BEFORE.test(before) && !asCodeObject) return true;
|
|
115
139
|
from = at + name.length;
|
|
116
140
|
}
|
|
117
141
|
return false;
|
|
118
142
|
}
|
|
119
143
|
|
|
144
|
+
/**
|
|
145
|
+
* 把一句话拆成动作、交付物和话题:只有交付物才参与任务类型与栏目绑定,
|
|
146
|
+
* 动作和话题都带来源,避免把「讲解数据库事务」当成交付栏目。
|
|
147
|
+
*/
|
|
148
|
+
export function taskFacetsOf(query) {
|
|
149
|
+
const value = text(query, 400);
|
|
150
|
+
if (!value) {
|
|
151
|
+
return {
|
|
152
|
+
action: { kind: 'unknown', evidence: 'none' },
|
|
153
|
+
deliverable: { kind: '', seriesId: '', evidence: 'none' },
|
|
154
|
+
topic: { text: '', evidence: 'none' },
|
|
155
|
+
conflict: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const found = ACTION_RULES.find(([, pattern]) => pattern.test(value));
|
|
159
|
+
const action = { kind: found ? found[0] : 'unknown', evidence: found ? 'query' : 'none' };
|
|
160
|
+
const codeIntent = CODE_TASK_WORDS.test(value);
|
|
161
|
+
const codeObject = CODE_OBJECT_NOUN.test(value);
|
|
162
|
+
const contentIntent = CONTENT_TASK_WORDS.test(value) || /(一期|栏目|视频|脚本)/.test(value);
|
|
163
|
+
const contentCreate = (action.kind === 'create' || action.kind === 'edit') && contentIntent;
|
|
164
|
+
const strongCodeAction = (action.kind === 'fix' || action.kind === 'review') && (codeIntent || codeObject);
|
|
165
|
+
let kind = '';
|
|
166
|
+
if (strongCodeAction || (codeObject && action.kind !== 'create')) kind = 'code';
|
|
167
|
+
else if (contentCreate) kind = 'content';
|
|
168
|
+
else if (codeIntent && !contentIntent) kind = 'code';
|
|
169
|
+
else if (contentIntent && !codeIntent) kind = 'content';
|
|
170
|
+
else if (codeIntent && contentIntent) kind = 'code';
|
|
171
|
+
|
|
172
|
+
const marker = TOPIC_AFTER.exec(value);
|
|
173
|
+
let topicText = marker ? marker[1].replace(/[,。;!?,.;!?\s]+$/g, '').replace(/(讲讲|聊聊|说说|展开|讲一下|说一下)$/g, '').trim() : '';
|
|
174
|
+
let topicEvidence = topicText ? 'query' : 'none';
|
|
175
|
+
if (!topicText) {
|
|
176
|
+
const object = TOPIC_OBJECT.exec(value);
|
|
177
|
+
if (object) {
|
|
178
|
+
topicText = object[1];
|
|
179
|
+
topicEvidence = 'inferred';
|
|
180
|
+
} else if (kind === 'code') {
|
|
181
|
+
const subject = TOPIC_SUBJECT.exec(value);
|
|
182
|
+
if (subject) {
|
|
183
|
+
topicText = subject[1];
|
|
184
|
+
topicEvidence = 'inferred';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const conflict = Boolean(codeIntent && contentIntent && !strongCodeAction
|
|
189
|
+
&& !(kind === 'content' && contentCreate && topicText));
|
|
190
|
+
return {
|
|
191
|
+
action,
|
|
192
|
+
deliverable: { kind, seriesId: '', evidence: kind ? 'query' : 'none' },
|
|
193
|
+
topic: { text: topicText, evidence: topicEvidence },
|
|
194
|
+
conflict,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 任务类型只作上下文判断,不替代已有栏目:交付物目标决定类型。 */
|
|
199
|
+
export function taskKindOf(query) {
|
|
200
|
+
return taskFacetsOf(query).deliverable.kind;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 从一句话里找出被明确撤销的栏目;只认「先不做 / 放一边」这类停做动作。 */
|
|
204
|
+
export function revokeSeriesBy(query, registeredSeries) {
|
|
205
|
+
const value = text(query, 400);
|
|
206
|
+
if (!value) return [];
|
|
207
|
+
const series = [...new Set((Array.isArray(registeredSeries) ? registeredSeries : []).map((item) => text(item, 40)).filter(Boolean))];
|
|
208
|
+
return series.filter((name) => {
|
|
209
|
+
let from = 0;
|
|
210
|
+
while (from <= value.length) {
|
|
211
|
+
const at = value.indexOf(name, from);
|
|
212
|
+
if (at < 0) return false;
|
|
213
|
+
const before = value.slice(Math.max(0, at - 12), at);
|
|
214
|
+
const after = value.slice(at + name.length);
|
|
215
|
+
if (REVOKE_SERIES_BEFORE.test(before) || REVOKE_SERIES_AFTER.test(after)) return true;
|
|
216
|
+
from = at + name.length;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 生成本轮请求的运行时任务上下文:身份字段只来自可信批次,
|
|
224
|
+
* 任务字段优先用已绑定锚点,未绑定时才看本句的任务类型。
|
|
225
|
+
*/
|
|
226
|
+
export function buildRuntimeContext(input = {}) {
|
|
227
|
+
const projectId = text(input.projectId, 240);
|
|
228
|
+
const sessionId = text(input.sessionId, 120);
|
|
229
|
+
const messageIds = (Array.isArray(input.messageIds) ? input.messageIds : []).map((id) => text(id, 120)).filter(Boolean);
|
|
230
|
+
const batchKey = text(input.batchKey, 120) || requestBatchKey(messageIds);
|
|
231
|
+
const anchor = input.anchor && typeof input.anchor === 'object' ? input.anchor : null;
|
|
232
|
+
const episode = input.episode && typeof input.episode === 'object' ? input.episode : null;
|
|
233
|
+
const anchorKind = text(anchor && anchor.taskKind, 40);
|
|
234
|
+
const episodeKind = text(episode && episode.taskKind, 40);
|
|
235
|
+
const facets = taskFacetsOf(input.query);
|
|
236
|
+
const inferredKind = facets.deliverable.kind;
|
|
237
|
+
const kind = episodeKind || anchorKind || inferredKind;
|
|
238
|
+
/** 存储下来的 query 推断仍是 query,不能因为落过盘就自升为用户确认。 */
|
|
239
|
+
const storedKindEvidence = text(episode && episode.kindEvidence, 20) || text(anchor && anchor.kindEvidence, 20);
|
|
240
|
+
const evidence = storedKindEvidence
|
|
241
|
+
|| (inferredKind ? 'query' : (episodeKind || anchorKind ? 'inferred' : 'none'));
|
|
242
|
+
const storedAction = text(episode && episode.actionKind, 20) || text(anchor && anchor.actionKind, 20);
|
|
243
|
+
const storedTopic = text(episode && episode.topicText, 120) || text(anchor && anchor.topicText, 120);
|
|
244
|
+
const topicEvidence = text(episode && episode.topicEvidence, 20) || text(anchor && anchor.topicEvidence, 20);
|
|
245
|
+
return {
|
|
246
|
+
projectId,
|
|
247
|
+
sessionId,
|
|
248
|
+
requestId: contextIdFor(projectId, batchKey, text(episode && episode.episodeId, 120) || text(anchor && anchor.episodeId, 120)),
|
|
249
|
+
controlEpoch: input.controlEpoch === undefined || input.controlEpoch === null ? '' : String(input.controlEpoch),
|
|
250
|
+
task: {
|
|
251
|
+
kind,
|
|
252
|
+
seriesId: text(episode && episode.seriesId, 80) || text(anchor && anchor.seriesId, 80),
|
|
253
|
+
artifactKey: text(episode && episode.artifactKey, 120) || text(anchor && anchor.artifactKey, 120),
|
|
254
|
+
phase: text(episode && episode.phase, 40) || text(anchor && anchor.phase, 40),
|
|
255
|
+
evidence,
|
|
256
|
+
seriesEvidence: text(episode && episode.seriesEvidence, 20) || text(anchor && anchor.seriesEvidence, 20)
|
|
257
|
+
|| (text(episode && episode.seriesId, 80) || text(anchor && anchor.seriesId, 80) ? 'inferred' : 'none'),
|
|
258
|
+
action: storedAction || facets.action.kind,
|
|
259
|
+
actionEvidence: storedAction ? 'query' : facets.action.evidence,
|
|
260
|
+
topic: storedTopic || facets.topic.text,
|
|
261
|
+
topicEvidence: storedTopic ? (topicEvidence || 'inferred') : facets.topic.evidence,
|
|
262
|
+
conflict: Boolean((episode && episode.conflict === true) || (anchor && anchor.conflict === true) || facets.conflict),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
120
267
|
export function requestBatchKey(messageIds) {
|
|
121
268
|
const ids = [...new Set((Array.isArray(messageIds) ? messageIds : []).map((id) => String(id || '')).filter(Boolean))].sort();
|
|
122
269
|
if (!ids.length) return '';
|
|
@@ -178,6 +325,12 @@ export function normalizeTasks(raw) {
|
|
|
178
325
|
...(text(anchor.taskKind, 40) ? { taskKind: text(anchor.taskKind, 40) } : {}),
|
|
179
326
|
...(text(anchor.seriesId, 40) ? { seriesId: text(anchor.seriesId, 40) } : {}),
|
|
180
327
|
...(text(anchor.phase, 40) ? { phase: text(anchor.phase, 40) } : {}),
|
|
328
|
+
...(text(anchor.kindEvidence, 20) ? { kindEvidence: text(anchor.kindEvidence, 20) } : {}),
|
|
329
|
+
...(text(anchor.seriesEvidence, 20) ? { seriesEvidence: text(anchor.seriesEvidence, 20) } : {}),
|
|
330
|
+
...(text(anchor.actionKind, 20) ? { actionKind: text(anchor.actionKind, 20) } : {}),
|
|
331
|
+
...(text(anchor.topicText, 120) ? { topicText: text(anchor.topicText, 120) } : {}),
|
|
332
|
+
...(text(anchor.topicEvidence, 20) ? { topicEvidence: text(anchor.topicEvidence, 20) } : {}),
|
|
333
|
+
...(anchor.conflict === true ? { conflict: true } : {}),
|
|
181
334
|
status,
|
|
182
335
|
bindingRevision: Number.isInteger(anchor.bindingRevision) && anchor.bindingRevision > 0 ? anchor.bindingRevision : 1,
|
|
183
336
|
sourceMessageIds: (Array.isArray(anchor.sourceMessageIds) ? anchor.sourceMessageIds : []).map((id) => text(id, 120)).filter(Boolean).slice(0, TASK_LIMITS.sourceMessages),
|
|
@@ -192,6 +345,12 @@ export function normalizeTasks(raw) {
|
|
|
192
345
|
...(text(episode.taskKind, 40) ? { taskKind: text(episode.taskKind, 40) } : {}),
|
|
193
346
|
...(text(episode.seriesId, 40) ? { seriesId: text(episode.seriesId, 40) } : {}),
|
|
194
347
|
...(text(episode.phase, 40) ? { phase: text(episode.phase, 40) } : {}),
|
|
348
|
+
...(text(episode.kindEvidence, 20) ? { kindEvidence: text(episode.kindEvidence, 20) } : {}),
|
|
349
|
+
...(text(episode.seriesEvidence, 20) ? { seriesEvidence: text(episode.seriesEvidence, 20) } : {}),
|
|
350
|
+
...(text(episode.actionKind, 20) ? { actionKind: text(episode.actionKind, 20) } : {}),
|
|
351
|
+
...(text(episode.topicText, 120) ? { topicText: text(episode.topicText, 120) } : {}),
|
|
352
|
+
...(text(episode.topicEvidence, 20) ? { topicEvidence: text(episode.topicEvidence, 20) } : {}),
|
|
353
|
+
...(episode.conflict === true ? { conflict: true } : {}),
|
|
195
354
|
status: ANCHOR_STATUSES.includes(episode.status) ? episode.status : 'unbound',
|
|
196
355
|
transitionId: text(episode.transitionId, 80),
|
|
197
356
|
previousEpisodeId: text(episode.previousEpisodeId, 80),
|
|
@@ -245,6 +404,12 @@ function anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRev
|
|
|
245
404
|
...(episode.taskKind ? { taskKind: episode.taskKind } : {}),
|
|
246
405
|
...(episode.seriesId ? { seriesId: episode.seriesId } : {}),
|
|
247
406
|
...(episode.phase ? { phase: episode.phase } : {}),
|
|
407
|
+
...(episode.kindEvidence ? { kindEvidence: episode.kindEvidence } : {}),
|
|
408
|
+
...(episode.seriesEvidence ? { seriesEvidence: episode.seriesEvidence } : {}),
|
|
409
|
+
...(episode.actionKind ? { actionKind: episode.actionKind } : {}),
|
|
410
|
+
...(episode.topicText ? { topicText: episode.topicText } : {}),
|
|
411
|
+
...(episode.topicEvidence ? { topicEvidence: episode.topicEvidence } : {}),
|
|
412
|
+
...(episode.conflict === true ? { conflict: true } : {}),
|
|
248
413
|
status,
|
|
249
414
|
bindingRevision,
|
|
250
415
|
batchKey,
|
|
@@ -254,7 +419,7 @@ function anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRev
|
|
|
254
419
|
};
|
|
255
420
|
}
|
|
256
421
|
|
|
257
|
-
function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId, previousEpisodeId, taskText, now }) {
|
|
422
|
+
function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId, previousEpisodeId, taskText, kindEvidence, seriesEvidence, actionKind, topicText, topicEvidence, conflict, now }) {
|
|
258
423
|
const episodeId = episodeIdFor(transitionId);
|
|
259
424
|
const at = new Date(now).toISOString();
|
|
260
425
|
const episode = {
|
|
@@ -262,6 +427,12 @@ function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId,
|
|
|
262
427
|
...(taskKind ? { taskKind } : {}),
|
|
263
428
|
...(seriesId ? { seriesId } : {}),
|
|
264
429
|
...(phase ? { phase } : {}),
|
|
430
|
+
...(kindEvidence ? { kindEvidence } : {}),
|
|
431
|
+
...(seriesEvidence ? { seriesEvidence } : {}),
|
|
432
|
+
...(actionKind ? { actionKind } : {}),
|
|
433
|
+
...(topicText ? { topicText } : {}),
|
|
434
|
+
...(topicEvidence ? { topicEvidence } : {}),
|
|
435
|
+
...(conflict === true ? { conflict: true } : {}),
|
|
265
436
|
status,
|
|
266
437
|
transitionId,
|
|
267
438
|
...(previousEpisodeId ? { previousEpisodeId } : {}),
|
|
@@ -314,8 +485,27 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
314
485
|
/** 用户在这句话里点名的栏目优先于旧栏目和项目默认值。 */
|
|
315
486
|
const namedSeries = registered.find((series) => seriesChosenBy(query, series)) || '';
|
|
316
487
|
const changedSeries = current && namedSeries && namedSeries !== (current.seriesId || '') ? namedSeries : '';
|
|
317
|
-
const
|
|
318
|
-
|
|
488
|
+
const projectDefaults = input.defaults || tasks.defaults;
|
|
489
|
+
const facets = taskFacetsOf(query);
|
|
490
|
+
const queryKind = facets.deliverable.kind;
|
|
491
|
+
/** 调用方显式给的类型才是 explicit;从本句推断出的 code 只算 query 证据。 */
|
|
492
|
+
const inputKind = text(input.taskKind, 40) || (input.selection && text(input.selection.taskKind, 40)) || '';
|
|
493
|
+
const inferredKind = queryKind === 'code' ? 'code' : '';
|
|
494
|
+
/** content 是弱类型:只在没有旧栏目/项目默认值可继承时,才作为本句推断落盘。 */
|
|
495
|
+
const contentKind = queryKind === 'content' ? 'content' : '';
|
|
496
|
+
const explicitKind = inputKind || inferredKind;
|
|
497
|
+
const kindEvidence = inputKind ? 'explicit' : (inferredKind ? 'query' : '');
|
|
498
|
+
const inheritedKindEvidence = (currentEpisode && currentEpisode.kindEvidence) || (kindEvidence === 'query' ? 'query' : 'inferred');
|
|
499
|
+
const inheritedSeriesEvidence = (currentEpisode && currentEpisode.seriesEvidence) || 'inferred';
|
|
500
|
+
const fallbackKind = explicitKind || (currentEpisode && currentEpisode.taskKind)
|
|
501
|
+
|| (projectDefaults && projectDefaults.taskKind) || '';
|
|
502
|
+
const fallbackKindEvidence = explicitKind ? kindEvidence
|
|
503
|
+
: ((currentEpisode && currentEpisode.taskKind) ? inheritedKindEvidence : (projectDefaults && projectDefaults.taskKind ? 'explicit' : ''));
|
|
504
|
+
const facetFields = {
|
|
505
|
+
...(facets.action.kind !== 'unknown' ? { actionKind: facets.action.kind } : {}),
|
|
506
|
+
...(facets.topic.text ? { topicText: facets.topic.text, topicEvidence: facets.topic.evidence } : {}),
|
|
507
|
+
...(facets.conflict ? { conflict: true } : {}),
|
|
508
|
+
};
|
|
319
509
|
|
|
320
510
|
if ((!current || current.status === 'unbound' || current.status === 'proposed') && input.selection && (input.selection.seriesId || input.selection.taskKind)) {
|
|
321
511
|
const transitionId = transitionIdFor(batchKey, current ? current.bindingRevision || 0 : 0);
|
|
@@ -326,9 +516,10 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
326
516
|
if (shell) {
|
|
327
517
|
episode = currentEpisode;
|
|
328
518
|
Object.assign(episode, {
|
|
329
|
-
...(input.selection.taskKind ? { taskKind: input.selection.taskKind } : {}),
|
|
330
|
-
...(input.selection.seriesId ? { seriesId: input.selection.seriesId } : {}),
|
|
519
|
+
...(input.selection.taskKind ? { taskKind: input.selection.taskKind, kindEvidence: 'explicit' } : {}),
|
|
520
|
+
...(input.selection.seriesId ? { seriesId: input.selection.seriesId, seriesEvidence: 'explicit' } : {}),
|
|
331
521
|
phase: input.selection.phase || 'brief',
|
|
522
|
+
...facetFields,
|
|
332
523
|
});
|
|
333
524
|
touch(tasks, episode, { status: 'proposed' }, now);
|
|
334
525
|
} else {
|
|
@@ -338,6 +529,9 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
338
529
|
phase: input.selection.phase || 'brief',
|
|
339
530
|
status: 'proposed',
|
|
340
531
|
transitionId,
|
|
532
|
+
kindEvidence: input.selection.taskKind ? 'explicit' : '',
|
|
533
|
+
seriesEvidence: input.selection.seriesId ? 'explicit' : '',
|
|
534
|
+
...facetFields,
|
|
341
535
|
taskText: query,
|
|
342
536
|
now,
|
|
343
537
|
});
|
|
@@ -352,6 +546,32 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
352
546
|
return { tasks, anchor: current, episode: currentEpisode, decision: { kind: 'none', reason: 'thanks', pointer: true }, changed: false };
|
|
353
547
|
}
|
|
354
548
|
|
|
549
|
+
/** 明确撤销旧栏目要先于续作和项目默认值:说了「先不做口播」就不能再继承口播正文。 */
|
|
550
|
+
const revocable = [...new Set([...registered, current && current.seriesId, currentEpisode && currentEpisode.seriesId,
|
|
551
|
+
projectDefaults && projectDefaults.seriesId].filter(Boolean))];
|
|
552
|
+
const revoked = revokeSeriesBy(query, revocable);
|
|
553
|
+
if (revoked.length) {
|
|
554
|
+
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
555
|
+
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
556
|
+
const nextSeries = namedSeries && !revoked.includes(namedSeries) ? namedSeries : '';
|
|
557
|
+
const nextKind = explicitKind;
|
|
558
|
+
const status = nextSeries || nextKind ? 'active' : 'unbound';
|
|
559
|
+
const episode = existing || createEpisode(tasks, {
|
|
560
|
+
...(nextKind ? { taskKind: nextKind, kindEvidence } : {}),
|
|
561
|
+
...(nextSeries ? { seriesId: nextSeries, seriesEvidence: 'query' } : {}),
|
|
562
|
+
status,
|
|
563
|
+
phase: 'brief',
|
|
564
|
+
transitionId,
|
|
565
|
+
...facetFields,
|
|
566
|
+
...(currentEpisode ? { previousEpisodeId: currentEpisode.episodeId } : {}),
|
|
567
|
+
taskText: query,
|
|
568
|
+
now,
|
|
569
|
+
});
|
|
570
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
571
|
+
tasks.sessions[sessionId] = anchor;
|
|
572
|
+
return { tasks, anchor, episode, decision: { kind: 'revoked', anchor, revoked: [...revoked], ...(nextKind ? { taskKind: nextKind } : {}) }, changed: true };
|
|
573
|
+
}
|
|
574
|
+
|
|
355
575
|
/** 先认新对象,再认续作:带「继续 / 这个」但换了对象,也必须撤销旧范围。
|
|
356
576
|
* 本句点名了已登记栏目(「再做一期口播」)就是明确的对象证据,不算换任务。 */
|
|
357
577
|
if (current && currentEpisode && currentEpisode.status !== 'unbound'
|
|
@@ -360,16 +580,18 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
360
580
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
361
581
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
362
582
|
const episode = existing || createEpisode(tasks, {
|
|
583
|
+
...(explicitKind ? { taskKind: explicitKind, kindEvidence } : {}),
|
|
363
584
|
status: 'unbound',
|
|
364
585
|
phase: 'brief',
|
|
365
586
|
transitionId,
|
|
587
|
+
...facetFields,
|
|
366
588
|
previousEpisodeId: currentEpisode.episodeId,
|
|
367
589
|
taskText: query,
|
|
368
590
|
now,
|
|
369
591
|
});
|
|
370
592
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'unbound', bindingRevision: tasks.revision, now });
|
|
371
593
|
tasks.sessions[sessionId] = anchor;
|
|
372
|
-
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor, reason: 'new_task' }, changed: true };
|
|
594
|
+
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor, reason: 'new_task', ...(explicitKind ? { taskKind: explicitKind } : {}) }, changed: true };
|
|
373
595
|
}
|
|
374
596
|
|
|
375
597
|
if (isContinuation(query) && current && currentEpisode) {
|
|
@@ -380,25 +602,26 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
380
602
|
const seriesId = changedSeries || currentEpisode.seriesId || '';
|
|
381
603
|
const status = changedSeries ? 'active' : 'continuable';
|
|
382
604
|
const episode = existing || createEpisode(tasks, {
|
|
383
|
-
...(fallbackKind ? { taskKind: fallbackKind } : {}),
|
|
384
|
-
...(seriesId ? { seriesId } : {}),
|
|
605
|
+
...(fallbackKind ? { taskKind: fallbackKind, kindEvidence: fallbackKindEvidence } : {}),
|
|
606
|
+
...(seriesId ? { seriesId, seriesEvidence: changedSeries ? 'query' : inheritedSeriesEvidence } : {}),
|
|
385
607
|
phase: 'brief',
|
|
386
608
|
status,
|
|
387
609
|
transitionId,
|
|
610
|
+
...facetFields,
|
|
388
611
|
previousEpisodeId: currentEpisode.episodeId,
|
|
389
612
|
taskText: query,
|
|
390
613
|
now,
|
|
391
614
|
});
|
|
392
615
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
393
616
|
tasks.sessions[sessionId] = anchor;
|
|
394
|
-
return { tasks, anchor, episode, decision: { kind: 'continuation', anchor, inherited: currentEpisode }, changed: true };
|
|
617
|
+
return { tasks, anchor, episode, decision: { kind: 'continuation', anchor, inherited: currentEpisode, ...(episode && episode.taskKind ? { taskKind: episode.taskKind } : {}) }, changed: true };
|
|
395
618
|
}
|
|
396
619
|
|
|
397
620
|
if (isRevision(query) && current && currentEpisode) {
|
|
398
621
|
touch(tasks, currentEpisode, { status: currentEpisode.status === 'proposed' ? 'proposed' : 'continuable' }, now);
|
|
399
622
|
const anchor = anchorOf(currentEpisode, { sessionId, batchKey, messageIds, status: currentEpisode.status === 'proposed' ? 'proposed' : 'continuable', bindingRevision: tasks.revision, now });
|
|
400
623
|
tasks.sessions[sessionId] = anchor;
|
|
401
|
-
return { tasks, anchor, episode: currentEpisode, decision: { kind: 'revision', anchor }, changed: true };
|
|
624
|
+
return { tasks, anchor, episode: currentEpisode, decision: { kind: 'revision', anchor, ...(currentEpisode && currentEpisode.taskKind ? { taskKind: currentEpisode.taskKind } : {}) }, changed: true };
|
|
402
625
|
}
|
|
403
626
|
|
|
404
627
|
if (current && (SWITCH_WORDS.test(query) || changedSeries)) {
|
|
@@ -408,36 +631,41 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
408
631
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
409
632
|
const status = nextSeries ? 'active' : 'unbound';
|
|
410
633
|
const episode = existing || createEpisode(tasks, {
|
|
411
|
-
...(fallbackKind ? { taskKind: fallbackKind } : {}),
|
|
412
|
-
...(nextSeries ? { seriesId: nextSeries } : {}),
|
|
634
|
+
...(fallbackKind ? { taskKind: fallbackKind, kindEvidence: fallbackKindEvidence } : {}),
|
|
635
|
+
...(nextSeries ? { seriesId: nextSeries, seriesEvidence: changedSeries ? 'query' : inheritedSeriesEvidence } : {}),
|
|
413
636
|
status,
|
|
414
637
|
phase: 'brief',
|
|
415
638
|
transitionId,
|
|
639
|
+
...facetFields,
|
|
416
640
|
previousEpisodeId: currentEpisode ? currentEpisode.episodeId : '',
|
|
417
641
|
taskText: query,
|
|
418
642
|
now,
|
|
419
643
|
});
|
|
420
644
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
421
645
|
tasks.sessions[sessionId] = anchor;
|
|
422
|
-
return { tasks, anchor, episode, decision: { kind: nextSeries ? 'selected' : 'unbound', anchor, switched: true }, changed: true };
|
|
646
|
+
return { tasks, anchor, episode, decision: { kind: nextSeries ? 'selected' : 'unbound', anchor, switched: true, ...(episode && episode.taskKind ? { taskKind: episode.taskKind } : {}) }, changed: true };
|
|
423
647
|
}
|
|
424
648
|
|
|
425
649
|
/** 新会话或尚无有效栏目时,本句点名的已登记栏目直接生效,不被项目默认值覆盖。 */
|
|
426
650
|
if (namedSeries && !changedSeries && (!current || current.status === 'unbound' || current.status === 'proposed')) {
|
|
427
651
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
428
652
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
653
|
+
const namedKind = fallbackKind || contentKind;
|
|
654
|
+
const namedKindEvidence = fallbackKind ? fallbackKindEvidence : (contentKind ? 'query' : '');
|
|
429
655
|
const episode = existing || createEpisode(tasks, {
|
|
430
|
-
...(
|
|
656
|
+
...(namedKind ? { taskKind: namedKind, kindEvidence: namedKindEvidence } : {}),
|
|
431
657
|
seriesId: namedSeries,
|
|
658
|
+
seriesEvidence: 'query',
|
|
432
659
|
status: 'active',
|
|
433
660
|
phase: 'brief',
|
|
434
661
|
transitionId,
|
|
662
|
+
...facetFields,
|
|
435
663
|
taskText: query,
|
|
436
664
|
now,
|
|
437
665
|
});
|
|
438
666
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'active', bindingRevision: tasks.revision, now });
|
|
439
667
|
tasks.sessions[sessionId] = anchor;
|
|
440
|
-
return { tasks, anchor, episode, decision: { kind: 'selected', anchor, explicit: true }, changed: true };
|
|
668
|
+
return { tasks, anchor, episode, decision: { kind: 'selected', anchor, explicit: true, ...(episode && episode.taskKind ? { taskKind: episode.taskKind } : {}) }, changed: true };
|
|
441
669
|
}
|
|
442
670
|
|
|
443
671
|
if (current && currentEpisode) {
|
|
@@ -454,11 +682,11 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
454
682
|
now,
|
|
455
683
|
});
|
|
456
684
|
tasks.sessions[sessionId] = anchor;
|
|
457
|
-
return { tasks, anchor, episode: currentEpisode, decision: { kind: 'focus', anchor }, changed: false };
|
|
685
|
+
return { tasks, anchor, episode: currentEpisode, decision: { kind: 'focus', anchor, ...(currentEpisode && currentEpisode.taskKind ? { taskKind: currentEpisode.taskKind } : {}) }, changed: false };
|
|
458
686
|
}
|
|
459
687
|
|
|
460
|
-
const defaults =
|
|
461
|
-
if (defaults && (defaults.seriesId || defaults.taskKind)) {
|
|
688
|
+
const defaults = projectDefaults;
|
|
689
|
+
if (!explicitKind && defaults && (defaults.seriesId || defaults.taskKind)) {
|
|
462
690
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
463
691
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
464
692
|
const episode = existing || createEpisode(tasks, {
|
|
@@ -467,21 +695,28 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
467
695
|
phase: 'brief',
|
|
468
696
|
status: 'active',
|
|
469
697
|
transitionId,
|
|
698
|
+
kindEvidence: defaults.taskKind ? 'explicit' : '',
|
|
699
|
+
seriesEvidence: defaults.seriesId ? 'explicit' : '',
|
|
700
|
+
...facetFields,
|
|
470
701
|
taskText: query,
|
|
471
702
|
now,
|
|
472
703
|
});
|
|
473
704
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'active', bindingRevision: tasks.revision, now });
|
|
474
705
|
tasks.sessions[sessionId] = anchor;
|
|
475
|
-
return { tasks, anchor, episode, decision: { kind: 'default', anchor }, changed: true };
|
|
706
|
+
return { tasks, anchor, episode, decision: { kind: 'default', anchor, ...(episode && episode.taskKind ? { taskKind: episode.taskKind } : {}) }, changed: true };
|
|
476
707
|
}
|
|
477
708
|
|
|
478
|
-
/** 新会话/新任务、无法确定栏目:只建无正文作品壳,不猜
|
|
709
|
+
/** 新会话/新任务、无法确定栏目:只建无正文作品壳,不猜 seriesId;显式任务类型要留下。 */
|
|
479
710
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
480
711
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
481
|
-
const
|
|
482
|
-
const
|
|
712
|
+
const status = explicitKind ? 'active' : 'unbound';
|
|
713
|
+
const episode = existing || createEpisode(tasks, {
|
|
714
|
+
...(explicitKind ? { taskKind: explicitKind, kindEvidence } : {}), status, phase: 'brief', transitionId,
|
|
715
|
+
...facetFields, taskText: query, now,
|
|
716
|
+
});
|
|
717
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
483
718
|
tasks.sessions[sessionId] = anchor;
|
|
484
|
-
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor }, changed: true };
|
|
719
|
+
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor, ...(explicitKind ? { taskKind: explicitKind } : {}) }, changed: true };
|
|
485
720
|
}
|
|
486
721
|
|
|
487
722
|
export function contextOfAnchor(anchor, base = {}) {
|