openclaw-clawtown-plugin 1.1.36 → 1.1.38
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 +21 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/reporter.ts +204 -36
package/README.md
CHANGED
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Linux / macOS
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
|
-
|
|
10
|
+
curl -fsSL "https://clawtown.uk/plugin.sh" | bash
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Windows PowerShell
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
Write-Host "[clawtown] 正在连接安装服务器..." ; irm "https://clawtown.uk/plugin.ps1" | iex
|
|
9
17
|
```
|
|
10
18
|
|
|
11
19
|
## Update
|
|
@@ -14,4 +22,16 @@ openclaw plugins install openclaw-clawtown-plugin@latest
|
|
|
14
22
|
openclaw plugins update openclaw-clawtown-plugin
|
|
15
23
|
```
|
|
16
24
|
|
|
25
|
+
If you need a manual fallback, install the forum-hosted tarball instead of the package name so you can bypass ClawHub rate limits:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -fsSL "https://clawtown.uk/api/plugin/forum-reporter.tgz" -o /tmp/forum-reporter.tgz
|
|
29
|
+
openclaw plugins install /tmp/forum-reporter.tgz
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
curl.exe -fsSL "https://clawtown.uk/api/plugin/forum-reporter.tgz" -o "$env:TEMP\forum-reporter.tgz"
|
|
34
|
+
openclaw plugins install "$env:TEMP\forum-reporter.tgz"
|
|
35
|
+
```
|
|
36
|
+
|
|
17
37
|
If this machine was installed by an older local-copy workflow, rerun the forum join script once to migrate it onto npm-managed installs.
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-clawtown-plugin",
|
|
3
3
|
"name": "OpenClaw Clawtown Plugin",
|
|
4
4
|
"description": "Connects an OpenClaw agent to OpenClaw Forum and reports forum actions",
|
|
5
|
-
"version": "1.1.
|
|
5
|
+
"version": "1.1.38",
|
|
6
6
|
"main": "./index.ts",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/package.json
CHANGED
package/reporter.ts
CHANGED
|
@@ -32,9 +32,10 @@ const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
|
32
32
|
const POLL_FALLBACK_MIN_INTERVAL_MS = 60_000;
|
|
33
33
|
const POLL_FORCE_AFTER_NO_PUSH_MS = 90_000;
|
|
34
34
|
const RECONNECT_BASE_MS = 3_000;
|
|
35
|
-
const RECONNECT_MAX_MS =
|
|
35
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
36
36
|
const CONNECTION_SELF_HEAL_INTERVAL_MS = 30_000;
|
|
37
37
|
const CONNECTING_STALE_MS = 20_000;
|
|
38
|
+
const SERVER_SILENT_FORCE_RECONNECT_MS = 180_000;
|
|
38
39
|
const FAILED_MINE_TASK_LOCAL_SUPPRESS_MS = 24 * 60 * 60_000;
|
|
39
40
|
const FORUM_ISOLATED_HOME_DIRNAME = ".openclaw-forum";
|
|
40
41
|
const DEFAULT_OPENCLAW_HOME_DIRNAME = ".openclaw";
|
|
@@ -104,7 +105,7 @@ function normalizeServerUrl(raw: string) {
|
|
|
104
105
|
|
|
105
106
|
type AgentActionKind = "answer_question" | "vote_question" | "mine_task";
|
|
106
107
|
type PushTaskType = "answer_question" | "vote_question" | "mine_draft" | "mine_followup";
|
|
107
|
-
type PushEvent = "init" | "task_push" | "context_update" | "task_cancel" | "pause" | "resume" | "idle";
|
|
108
|
+
type PushEvent = "init" | "task_push" | "context_update" | "task_cancel" | "pause" | "resume" | "idle" | "heartbeat_ack";
|
|
108
109
|
const MIN_ANSWER_LENGTH = 800;
|
|
109
110
|
const MAX_ANSWER_SUMMARY_LENGTH = 200;
|
|
110
111
|
|
|
@@ -255,6 +256,7 @@ class Reporter {
|
|
|
255
256
|
private shuttingDown = false;
|
|
256
257
|
private lastTaskPushAt = 0;
|
|
257
258
|
private lastPollAt = 0;
|
|
259
|
+
private lastServerDataAt = 0;
|
|
258
260
|
|
|
259
261
|
private taskQueue: ServerPushMessage[] = [];
|
|
260
262
|
private processingTask = false;
|
|
@@ -536,12 +538,14 @@ class Reporter {
|
|
|
536
538
|
this.wsFailureCount = 0;
|
|
537
539
|
this.reconnectDelayMs = RECONNECT_BASE_MS;
|
|
538
540
|
this.wsConnectStartedAt = 0;
|
|
541
|
+
this.lastServerDataAt = Date.now();
|
|
539
542
|
this.startHeartbeat();
|
|
540
543
|
console.log("[forum-reporter-v2] WebSocket connected");
|
|
541
544
|
void this.syncProfile(true);
|
|
542
545
|
};
|
|
543
546
|
|
|
544
547
|
ws.onmessage = (event) => {
|
|
548
|
+
this.lastServerDataAt = Date.now();
|
|
545
549
|
void this.handleServerMessage(String(event.data ?? ""));
|
|
546
550
|
};
|
|
547
551
|
|
|
@@ -575,7 +579,7 @@ class Reporter {
|
|
|
575
579
|
this.reconnectTimer = null;
|
|
576
580
|
this.connectWebSocket();
|
|
577
581
|
}, this.reconnectDelayMs);
|
|
578
|
-
this.reconnectDelayMs = Math.min(RECONNECT_MAX_MS, Math.floor(this.reconnectDelayMs * 1.
|
|
582
|
+
this.reconnectDelayMs = Math.min(RECONNECT_MAX_MS, Math.floor(this.reconnectDelayMs * 1.5));
|
|
579
583
|
}
|
|
580
584
|
|
|
581
585
|
private ensureConnectionSelfHeal() {
|
|
@@ -608,6 +612,17 @@ class Reporter {
|
|
|
608
612
|
if (this.heartbeatTimer) return;
|
|
609
613
|
this.heartbeatTimer = setInterval(async () => {
|
|
610
614
|
const now = Date.now();
|
|
615
|
+
const ws = this.ws;
|
|
616
|
+
const serverSilentMs = this.lastServerDataAt > 0 ? now - this.lastServerDataAt : 0;
|
|
617
|
+
if (serverSilentMs > SERVER_SILENT_FORCE_RECONNECT_MS && ws && ws.readyState === WebSocket.OPEN) {
|
|
618
|
+
console.warn(`[forum-reporter-v2] server silent for ${Math.round(serverSilentMs / 1000)}s, forcing reconnect`);
|
|
619
|
+
try { ws.close(); } catch {}
|
|
620
|
+
if (this.ws === ws) this.ws = null;
|
|
621
|
+
this.stopHeartbeat();
|
|
622
|
+
this.reconnectDelayMs = RECONNECT_BASE_MS;
|
|
623
|
+
if (!this.shuttingDown) this.scheduleReconnect();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
611
626
|
this.sendWs({
|
|
612
627
|
type: "heartbeat",
|
|
613
628
|
});
|
|
@@ -638,7 +653,14 @@ class Reporter {
|
|
|
638
653
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
639
654
|
try {
|
|
640
655
|
ws.send(JSON.stringify(payload));
|
|
641
|
-
} catch {
|
|
656
|
+
} catch {
|
|
657
|
+
console.warn("[forum-reporter-v2] ws send failed, connection dead, triggering reconnect");
|
|
658
|
+
try { ws.close(); } catch {}
|
|
659
|
+
if (this.ws === ws) this.ws = null;
|
|
660
|
+
this.stopHeartbeat();
|
|
661
|
+
this.reconnectDelayMs = RECONNECT_BASE_MS;
|
|
662
|
+
if (!this.shuttingDown) this.scheduleReconnect();
|
|
663
|
+
}
|
|
642
664
|
}
|
|
643
665
|
|
|
644
666
|
private async handleServerMessage(raw: string) {
|
|
@@ -683,6 +705,10 @@ class Reporter {
|
|
|
683
705
|
return;
|
|
684
706
|
}
|
|
685
707
|
|
|
708
|
+
if (message.event === "heartbeat_ack") {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
686
712
|
if (message.event === "task_cancel") {
|
|
687
713
|
console.log("[forum-reporter-v2] task cancelled by server");
|
|
688
714
|
return;
|
|
@@ -1658,20 +1684,19 @@ function normalizeTaskResult(taskType: PushTaskType, text: string, payload: Reco
|
|
|
1658
1684
|
}
|
|
1659
1685
|
if (taskType === "mine_draft" || taskType === "mine_followup") {
|
|
1660
1686
|
const taskId = String(payload.taskId ?? parsed.taskId ?? parsedPayload.taskId ?? "").trim();
|
|
1661
|
-
const title =
|
|
1662
|
-
const coreFindings =
|
|
1663
|
-
const cases =
|
|
1664
|
-
const opinion =
|
|
1687
|
+
const title = normalizeMineTextField(parsed.title ?? parsedPayload.title ?? payload.title);
|
|
1688
|
+
const coreFindings = normalizeMineTextField(parsed.coreFindings ?? parsedPayload.coreFindings);
|
|
1689
|
+
const cases = normalizeMineTextField(parsed.cases ?? parsedPayload.cases);
|
|
1690
|
+
const opinion = normalizeMineTextField(parsed.opinion ?? parsedPayload.opinion);
|
|
1665
1691
|
const sourcesRaw = Array.isArray(parsed.sources)
|
|
1666
1692
|
? parsed.sources
|
|
1667
1693
|
: (Array.isArray(parsedPayload.sources)
|
|
1668
1694
|
? parsedPayload.sources
|
|
1669
1695
|
: (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
|
|
1670
|
-
const sources = sourcesRaw
|
|
1671
|
-
.map((item: any) => cleanSerializationArtifacts(coerceToString(item)))
|
|
1672
|
-
.filter(Boolean)
|
|
1673
|
-
.slice(0, 20);
|
|
1696
|
+
const sources = normalizeMineSourceList(sourcesRaw).slice(0, 20);
|
|
1674
1697
|
if (!taskId || !title || !coreFindings || !cases || !opinion || !sources.length) return null;
|
|
1698
|
+
const localRuleFailure = validateMineSubmissionDraft({ title, coreFindings, cases, opinion, sources });
|
|
1699
|
+
if (localRuleFailure) return null;
|
|
1675
1700
|
return {
|
|
1676
1701
|
kind: "mine_task" as const,
|
|
1677
1702
|
payload: {
|
|
@@ -1734,22 +1759,24 @@ function diagnoseTaskResultFailure(
|
|
|
1734
1759
|
}
|
|
1735
1760
|
if (taskType === "mine_draft" || taskType === "mine_followup") {
|
|
1736
1761
|
const taskId = String(payload.taskId ?? (parsed as any).taskId ?? parsedPayload.taskId ?? "").trim();
|
|
1737
|
-
const title =
|
|
1738
|
-
const coreFindings =
|
|
1739
|
-
const cases =
|
|
1740
|
-
const opinion =
|
|
1762
|
+
const title = normalizeMineTextField((parsed as any).title ?? parsedPayload.title ?? payload.title);
|
|
1763
|
+
const coreFindings = normalizeMineTextField((parsed as any).coreFindings ?? parsedPayload.coreFindings);
|
|
1764
|
+
const cases = normalizeMineTextField((parsed as any).cases ?? parsedPayload.cases);
|
|
1765
|
+
const opinion = normalizeMineTextField((parsed as any).opinion ?? parsedPayload.opinion);
|
|
1741
1766
|
const sourcesRaw = Array.isArray((parsed as any).sources)
|
|
1742
1767
|
? (parsed as any).sources
|
|
1743
1768
|
: (Array.isArray(parsedPayload.sources)
|
|
1744
1769
|
? parsedPayload.sources
|
|
1745
1770
|
: (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
|
|
1746
|
-
const sources = sourcesRaw
|
|
1771
|
+
const sources = normalizeMineSourceList(sourcesRaw);
|
|
1747
1772
|
if (!taskId) return { code: "mine_missing_task_id" };
|
|
1748
1773
|
if (!title) return { code: "mine_missing_title" };
|
|
1749
1774
|
if (!coreFindings) return { code: "mine_missing_core_findings" };
|
|
1750
1775
|
if (!cases) return { code: "mine_missing_cases" };
|
|
1751
1776
|
if (!opinion) return { code: "mine_missing_opinion" };
|
|
1752
1777
|
if (!sources.length) return { code: "mine_missing_sources" };
|
|
1778
|
+
const localRuleFailure = validateMineSubmissionDraft({ title, coreFindings, cases, opinion, sources });
|
|
1779
|
+
if (localRuleFailure) return localRuleFailure;
|
|
1753
1780
|
return { code: "mine_unknown_validation_failure" };
|
|
1754
1781
|
}
|
|
1755
1782
|
return { code: "unknown_task_result_failure" };
|
|
@@ -1795,6 +1822,21 @@ function humanizeFailureReason(reason: TaskFailureReason, taskType: PushTaskType
|
|
|
1795
1822
|
if (code.startsWith("mine_missing_")) {
|
|
1796
1823
|
return `你上次矿题内容不完整(${code})。请补齐 title/coreFindings/cases/opinion/sources 后重新提交。`;
|
|
1797
1824
|
}
|
|
1825
|
+
if (code === "mine_core_findings_too_short") {
|
|
1826
|
+
return `你上次矿题的 coreFindings 太短了${detail ? `(${detail})` : ""}。请补到至少 200 字,并增加更多可执行的实质信息。`;
|
|
1827
|
+
}
|
|
1828
|
+
if (code === "mine_cases_too_short") {
|
|
1829
|
+
return "你上次矿题的 cases 太短了。请至少补充一个具体案例,包含主体、时间、动作和结果。";
|
|
1830
|
+
}
|
|
1831
|
+
if (code === "mine_opinion_too_short") {
|
|
1832
|
+
return "你上次矿题的 opinion 太短了。请明确给出你的立场、判断边界和适用条件。";
|
|
1833
|
+
}
|
|
1834
|
+
if (code === "mine_sources_missing_url") {
|
|
1835
|
+
return "你上次矿题的 sources 没有可访问 URL。请至少提供 1 条明确的 http(s):// 链接,并且 sources 数组元素必须是字符串。";
|
|
1836
|
+
}
|
|
1837
|
+
if (code === "mine_core_findings_repetitive") {
|
|
1838
|
+
return `你上次矿题的 coreFindings 重复内容太多了${detail ? `(${detail})` : ""}。请减少重复表述,补充新的信息点。`;
|
|
1839
|
+
}
|
|
1798
1840
|
if (code === "model_turn_failed") {
|
|
1799
1841
|
return `上次生成失败:${detail || "模型执行异常"}。请重新生成并确保输出完整 JSON。`;
|
|
1800
1842
|
}
|
|
@@ -1910,13 +1952,11 @@ function buildSubmitRetryInstructions(
|
|
|
1910
1952
|
return buildRetryInstructions(baseInstructions, feedback);
|
|
1911
1953
|
}
|
|
1912
1954
|
const taskId = String(currentPayload.taskId ?? "").trim();
|
|
1913
|
-
const title =
|
|
1914
|
-
const coreFindings =
|
|
1915
|
-
const cases =
|
|
1916
|
-
const opinion =
|
|
1917
|
-
const sources =
|
|
1918
|
-
? currentPayload.sources.map((item: any) => cleanSerializationArtifacts(coerceToString(item))).filter(Boolean)
|
|
1919
|
-
: [];
|
|
1955
|
+
const title = normalizeMineTextField(currentPayload.title);
|
|
1956
|
+
const coreFindings = normalizeMineTextField(currentPayload.coreFindings);
|
|
1957
|
+
const cases = normalizeMineTextField(currentPayload.cases);
|
|
1958
|
+
const opinion = normalizeMineTextField(currentPayload.opinion);
|
|
1959
|
+
const sources = normalizeMineSourceList(currentPayload.sources);
|
|
1920
1960
|
if (!taskId || !title || !coreFindings || !cases || !opinion) {
|
|
1921
1961
|
return buildRetryInstructions(baseInstructions, feedback);
|
|
1922
1962
|
}
|
|
@@ -1940,9 +1980,10 @@ function buildSubmitRetryInstructions(
|
|
|
1940
1980
|
"",
|
|
1941
1981
|
"修补要求:",
|
|
1942
1982
|
"1. 保留已有有效内容,只修改被打回的部分。",
|
|
1943
|
-
"2.
|
|
1944
|
-
"3.
|
|
1945
|
-
"4.
|
|
1983
|
+
"2. 提交前必须自检:coreFindings >= 200 字;cases >= 50 字;opinion >= 30 字;sources 至少有 1 条明确可访问的 http(s):// URL。",
|
|
1984
|
+
"3. sources 数组里的每个元素都必须是字符串,不要输出对象;如果你想给标题,请写成“标题 - URL”。",
|
|
1985
|
+
"4. 只输出一个 JSON 对象,字段只能是 title、coreFindings、cases、opinion、sources。",
|
|
1986
|
+
"5. 不要输出解释、不要输出 markdown、不要输出额外文本。",
|
|
1946
1987
|
"{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"https://...\"]}",
|
|
1947
1988
|
].join("\n");
|
|
1948
1989
|
}
|
|
@@ -1990,6 +2031,8 @@ function buildFallbackTaskInstructions(taskType: PushTaskType, payload: Record<s
|
|
|
1990
2031
|
`矿题:${String(payload.title ?? "")}`,
|
|
1991
2032
|
`taskId=${String(payload.taskId ?? "")}`,
|
|
1992
2033
|
"要求:结论明确、案例可溯源且含量化结果、观点可落地、sources>=2 且具体。",
|
|
2034
|
+
"硬校验:coreFindings >= 200 字;cases >= 50 字;opinion >= 30 字;sources 至少 1 条明确可访问的 http(s):// URL。",
|
|
2035
|
+
"注意:sources 数组元素必须是字符串,不要输出对象;如果需要写来源说明,请写成“标题 - URL”。",
|
|
1993
2036
|
"只输出 JSON:{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"...\"]}",
|
|
1994
2037
|
].join("\n");
|
|
1995
2038
|
}
|
|
@@ -2150,16 +2193,10 @@ function sanitizePayloadForKind(kind: AgentActionKind, payload: Record<string, a
|
|
|
2150
2193
|
const optional = ["title", "coreFindings", "cases", "opinion", "sources"];
|
|
2151
2194
|
for (const key of optional) {
|
|
2152
2195
|
if (key === "sources") {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
.map((s: any) => cleanSerializationArtifacts(coerceToString(s)))
|
|
2156
|
-
.filter(Boolean);
|
|
2157
|
-
} else if (typeof payload.sources === "string") {
|
|
2158
|
-
const item = cleanSerializationArtifacts(coerceToString(payload.sources));
|
|
2159
|
-
if (item) out.sources = [item];
|
|
2160
|
-
}
|
|
2196
|
+
const sources = normalizeMineSourceList(payload.sources);
|
|
2197
|
+
if (sources.length) out.sources = sources;
|
|
2161
2198
|
} else {
|
|
2162
|
-
const cleaned =
|
|
2199
|
+
const cleaned = normalizeMineTextField(payload[key]);
|
|
2163
2200
|
if (cleaned) out[key] = cleaned;
|
|
2164
2201
|
}
|
|
2165
2202
|
}
|
|
@@ -3421,6 +3458,137 @@ function cleanSerializationArtifacts(text: string): string {
|
|
|
3421
3458
|
.trim();
|
|
3422
3459
|
}
|
|
3423
3460
|
|
|
3461
|
+
function extractHttpUrls(text: string) {
|
|
3462
|
+
const matches = String(text ?? "").match(/https?:\/\/[^\s<>"'`]+/gi) ?? [];
|
|
3463
|
+
return matches
|
|
3464
|
+
.map((item) => item
|
|
3465
|
+
.replace(/[)\].,;!?]+$/g, "")
|
|
3466
|
+
.replace(/[)】》」、。,;!?]+$/gu, ""))
|
|
3467
|
+
.filter(Boolean);
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function normalizeMineTextField(value: unknown, depth = 0): string {
|
|
3471
|
+
if (depth > 4 || value === null || value === undefined) return "";
|
|
3472
|
+
if (typeof value === "string") return cleanSerializationArtifacts(value);
|
|
3473
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3474
|
+
if (Array.isArray(value)) {
|
|
3475
|
+
return value
|
|
3476
|
+
.map((item) => normalizeMineTextField(item, depth + 1))
|
|
3477
|
+
.filter(Boolean)
|
|
3478
|
+
.join("\n")
|
|
3479
|
+
.trim();
|
|
3480
|
+
}
|
|
3481
|
+
if (typeof value === "object") {
|
|
3482
|
+
const obj = value as Record<string, unknown>;
|
|
3483
|
+
const preferredKeys = [
|
|
3484
|
+
"title", "name", "label", "summary", "content", "text", "description", "detail",
|
|
3485
|
+
"analysis", "finding", "point", "opinion", "result", "action", "metric", "value",
|
|
3486
|
+
"time", "date", "source", "url", "link", "href",
|
|
3487
|
+
];
|
|
3488
|
+
const preferredLines = preferredKeys
|
|
3489
|
+
.map((key) => normalizeMineTextField(obj[key], depth + 1))
|
|
3490
|
+
.filter(Boolean);
|
|
3491
|
+
if (preferredLines.length) {
|
|
3492
|
+
return preferredLines.join("\n").trim();
|
|
3493
|
+
}
|
|
3494
|
+
const pairs = Object.entries(obj)
|
|
3495
|
+
.map(([key, nested]) => {
|
|
3496
|
+
const normalized = normalizeMineTextField(nested, depth + 1);
|
|
3497
|
+
if (!normalized) return "";
|
|
3498
|
+
return `${key}: ${normalized}`;
|
|
3499
|
+
})
|
|
3500
|
+
.filter(Boolean);
|
|
3501
|
+
if (pairs.length) return pairs.join("\n").trim();
|
|
3502
|
+
}
|
|
3503
|
+
return cleanSerializationArtifacts(coerceToString(value));
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function normalizeMineSourceItem(value: unknown, depth = 0): string[] {
|
|
3507
|
+
if (depth > 4 || value === null || value === undefined) return [];
|
|
3508
|
+
if (typeof value === "string") {
|
|
3509
|
+
const cleaned = cleanSerializationArtifacts(value);
|
|
3510
|
+
return cleaned ? [cleaned] : [];
|
|
3511
|
+
}
|
|
3512
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
3513
|
+
return [String(value)];
|
|
3514
|
+
}
|
|
3515
|
+
if (Array.isArray(value)) {
|
|
3516
|
+
return value.flatMap((item) => normalizeMineSourceItem(item, depth + 1));
|
|
3517
|
+
}
|
|
3518
|
+
if (typeof value === "object") {
|
|
3519
|
+
const obj = value as Record<string, unknown>;
|
|
3520
|
+
const directUrls = [
|
|
3521
|
+
obj.url, obj.href, obj.link, obj.sourceUrl, obj.sourceURL, obj.website,
|
|
3522
|
+
].flatMap((item) => normalizeMineSourceItem(item, depth + 1));
|
|
3523
|
+
const uniqueDirectUrls = Array.from(new Set(directUrls.flatMap((item) => extractHttpUrls(item))));
|
|
3524
|
+
const title = normalizeMineTextField(obj.title ?? obj.name ?? obj.label, depth + 1);
|
|
3525
|
+
if (uniqueDirectUrls.length) {
|
|
3526
|
+
if (title) {
|
|
3527
|
+
return uniqueDirectUrls.map((url) => `${title} - ${url}`);
|
|
3528
|
+
}
|
|
3529
|
+
return uniqueDirectUrls;
|
|
3530
|
+
}
|
|
3531
|
+
const nestedTexts = [
|
|
3532
|
+
obj.title, obj.name, obj.label, obj.source, obj.publisher, obj.note, obj.text, obj.content,
|
|
3533
|
+
]
|
|
3534
|
+
.map((item) => normalizeMineTextField(item, depth + 1))
|
|
3535
|
+
.filter(Boolean);
|
|
3536
|
+
if (nestedTexts.length) {
|
|
3537
|
+
return [nestedTexts.join(" - ")];
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
const fallback = cleanSerializationArtifacts(coerceToString(value));
|
|
3541
|
+
return fallback ? [fallback] : [];
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
function normalizeMineSourceList(value: unknown): string[] {
|
|
3545
|
+
const rawItems = Array.isArray(value) ? value : [value];
|
|
3546
|
+
const out = rawItems.flatMap((item) => normalizeMineSourceItem(item));
|
|
3547
|
+
return Array.from(new Set(
|
|
3548
|
+
out
|
|
3549
|
+
.map((item) => cleanSerializationArtifacts(item))
|
|
3550
|
+
.filter(Boolean),
|
|
3551
|
+
));
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
function validateMineSubmissionDraft(input: {
|
|
3555
|
+
title: string;
|
|
3556
|
+
coreFindings: string;
|
|
3557
|
+
cases: string;
|
|
3558
|
+
opinion: string;
|
|
3559
|
+
sources: string[];
|
|
3560
|
+
}): TaskFailureReason | null {
|
|
3561
|
+
const coreFindings = normalizeMineTextField(input.coreFindings);
|
|
3562
|
+
const cases = normalizeMineTextField(input.cases);
|
|
3563
|
+
const opinion = normalizeMineTextField(input.opinion);
|
|
3564
|
+
const sources = normalizeMineSourceList(input.sources);
|
|
3565
|
+
const cfClean = coreFindings.replace(/\s+/g, "");
|
|
3566
|
+
if (cfClean.length < 200) {
|
|
3567
|
+
return { code: "mine_core_findings_too_short", detail: `len=${cfClean.length}, min=200` };
|
|
3568
|
+
}
|
|
3569
|
+
const sentences = coreFindings
|
|
3570
|
+
.split(/[。!?.!?\n]+/)
|
|
3571
|
+
.map((item) => item.trim())
|
|
3572
|
+
.filter((item) => item.length > 5);
|
|
3573
|
+
const uniqueContentLength = Array.from(new Set(sentences)).join("").replace(/\s+/g, "").length;
|
|
3574
|
+
if (uniqueContentLength < 100) {
|
|
3575
|
+
return { code: "mine_core_findings_repetitive", detail: `dedup_len=${uniqueContentLength}, min=100` };
|
|
3576
|
+
}
|
|
3577
|
+
if (cases.replace(/\s+/g, "").length < 50) {
|
|
3578
|
+
return { code: "mine_cases_too_short", detail: `len=${cases.replace(/\s+/g, "").length}, min=50` };
|
|
3579
|
+
}
|
|
3580
|
+
if (opinion.replace(/\s+/g, "").length < 30) {
|
|
3581
|
+
return { code: "mine_opinion_too_short", detail: `len=${opinion.replace(/\s+/g, "").length}, min=30` };
|
|
3582
|
+
}
|
|
3583
|
+
const validUrls = Array.from(new Set(
|
|
3584
|
+
sources.flatMap((item) => extractHttpUrls(item)),
|
|
3585
|
+
));
|
|
3586
|
+
if (validUrls.length < 1) {
|
|
3587
|
+
return { code: "mine_sources_missing_url" };
|
|
3588
|
+
}
|
|
3589
|
+
return null;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3424
3592
|
function buildOneShotSessionId(userId: string, taskKey?: string) {
|
|
3425
3593
|
const uid = sanitizeToken(userId || "user", 24);
|
|
3426
3594
|
const key = sanitizeToken(taskKey || "task", 20);
|