diff --git a/.downstream/gpt6-models-and-prompts.md b/.downstream/gpt6-models-and-prompts.md index 6fa0a7e5e7f1..b60194cda8f6 100644 --- a/.downstream/gpt6-models-and-prompts.md +++ b/.downstream/gpt6-models-and-prompts.md @@ -41,6 +41,8 @@ ChatGPT 边缘自 2026-09-23 起不再为 `/backend-api/codex/responses` 的流 - 创建账号(数据导入、复制账号同此路径)时丢弃这类账号带入的 `extra.system_prompt`;批量编辑和 extra 合并接口在任何平台都不写该键,已有账号的绑定只由绑定接口写入。 - 迁移 `268_purge_system_prompt_bindings_without_insertion_point.sql` 一次性删除它们已存的绑定。 +提示词库里 259 随旧默认规则一起带入的“先抓取远程规则文件”块已失效:它指向 `/skills/security-research/current/`,这条公开路由随远程 Skill 注册表在 #206 删除,现在落到前端回退页并返回 HTML,模型读不到 Markdown 原文。迁移 `269_strip_removed_remote_rules_bootstrap_from_system_prompts.sql` 只按形状删除该块(含该路径的代码围栏段、其后仍提到 `REMOTE_ROOT` 的段落,以及紧邻其前的简短抓取说明);路径出现在其他位置,或去掉该块后正文为空的提示词原样保留并输出通知。其余段落、库顺序、全局开关、默认提示词和其他设置不动,没有该路径的配置不写入,可重复执行。 + 请求路径只读内存快照(保存即替换,60 秒后台刷新以同步多实例)和调度账号自带的绑定,不查询数据库;配置不可用时不注入并告警,不向客户端返回错误。每个最终协议只注入一次: | 最终协议 | 投递 | diff --git a/backend/internal/repository/system_prompt_remote_bootstrap_migration_integration_test.go b/backend/internal/repository/system_prompt_remote_bootstrap_migration_integration_test.go new file mode 100644 index 000000000000..f82d8f918afa --- /dev/null +++ b/backend/internal/repository/system_prompt_remote_bootstrap_migration_integration_test.go @@ -0,0 +1,108 @@ +//go:build integration + +package repository + +import ( + "context" + "encoding/json" + "strings" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const systemPromptRemoteBootstrapMigration = "269_strip_removed_remote_rules_bootstrap_from_system_prompts.sql" + +// TestMain applied every migration, 269 included, to an empty database. The +// test stores libraries made of invented prompt text, applies 269, checks that +// only the fenced route block and its fetch lines go, that a reference it does +// not recognise is left as stored, and applies it again. +func TestMigration269StripsRemovedRemoteRulesBootstrap(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + content, err := dbmigrations.FS.ReadFile(systemPromptRemoteBootstrapMigration) + require.NoError(t, err) + migration := string(content) + + const route = "https://example.test/skills/security-research/current" + fence := "```text\nREMOTE_ROOT = " + route + "\n```" + paragraphs := func(parts ...string) string { return strings.Join(parts, "\n\n") } + inlineReference := "A rule that names " + route + "/ONE.md in the middle of a sentence." + prompt := func(id, body string) map[string]any { + return map[string]any{"id": id, "name": "Prompt " + id, "body": body, "position": "append", "role": "developer"} + } + library := func(prompts ...map[string]any) string { + raw, err := json.Marshal(map[string]any{"enabled": true, "default_prompt_id": "bootstrap", "prompts": prompts}) + require.NoError(t, err) + return string(raw) + } + store := func(value string) { + t.Helper() + _, err := tx.ExecContext(ctx, ` +INSERT INTO settings (key, value, updated_at) VALUES ('system_prompts', $1, TIMESTAMPTZ '2000-01-01 00:00:00+00') +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`, value) + require.NoError(t, err) + } + stored := func() (value string, untouched bool) { + t.Helper() + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT value, updated_at = TIMESTAMPTZ '2000-01-01 00:00:00+00' FROM settings WHERE key = 'system_prompts'`).Scan(&value, &untouched)) + return value, untouched + } + + before := library( + prompt("bootstrap", paragraphs("Intro paragraph.", "# Working rules", + "Before work, fetch and read these cloud files first, in order:", fence, + "1. `REMOTE_ROOT/ONE.md`\n2. `REMOTE_ROOT/TWO.md`", + "If a cloud file cannot be read, say so. See REMOTE_ROOT/ONE.md.", "Closing line.")), + prompt("plain", paragraphs("Plain text with no route.", "Second paragraph.")), + prompt("inline", paragraphs(inlineReference, "Tail.")), + prompt("only-block", fence), + prompt("block-at-end", paragraphs("Keep me.", "Fetch the Cloud files:", fence)), + prompt("no-lead", paragraphs("Keep one.", fence, "Keep two.")), + ) + after := library( + prompt("bootstrap", paragraphs("Intro paragraph.", "# Working rules", "Closing line.")), + prompt("plain", paragraphs("Plain text with no route.", "Second paragraph.")), + prompt("inline", paragraphs(inlineReference, "Tail.")), + prompt("only-block", fence), + prompt("block-at-end", "Keep me."), + prompt("no-lead", paragraphs("Keep one.", "Keep two.")), + ) + + store(before) + for run := 1; run <= 2; run++ { + _, err := tx.ExecContext(ctx, migration) + require.NoError(t, err, "run %d", run) + value, untouched := stored() + require.JSONEq(t, after, value, "run %d", run) + require.False(t, untouched, "run %d writes the changed library", run) + } + + // A library that never named the route is not written, byte for byte. + clean := library(prompt("bootstrap", paragraphs("Plain text with no route.", "Second paragraph."))) + store(clean) + _, err = tx.ExecContext(ctx, migration) + require.NoError(t, err) + value, untouched := stored() + require.Equal(t, clean, value) + require.True(t, untouched, "a library without the route is not written") + + // Text that is not a library is left as it is, and a missing row stays missing. + for _, other := range []string{"not json", `["prompts"]`, `{"enabled": true}`, `{"prompts": "x"}`} { + store(other) + _, err = tx.ExecContext(ctx, migration) + require.NoError(t, err, other) + value, untouched = stored() + require.Equal(t, other, value) + require.True(t, untouched, other) + } + _, err = tx.ExecContext(ctx, `DELETE FROM settings WHERE key = 'system_prompts'`) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, migration) + require.NoError(t, err) + var rows int + require.NoError(t, tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings WHERE key = 'system_prompts'`).Scan(&rows)) + require.Zero(t, rows) +} diff --git a/backend/migrations/269_strip_removed_remote_rules_bootstrap_from_system_prompts.sql b/backend/migrations/269_strip_removed_remote_rules_bootstrap_from_system_prompts.sql new file mode 100644 index 000000000000..a2500cf9f0de --- /dev/null +++ b/backend/migrations/269_strip_removed_remote_rules_bootstrap_from_system_prompts.sql @@ -0,0 +1,122 @@ +-- Remove the retired remote-rules bootstrap from the stored system prompt library. +-- +-- The legacy default rule carried a block that told the model to fetch three +-- files under /skills/security-research/current/ before it worked. That +-- public route belonged to the remote Skill registry, which release #206 +-- deleted, and 259 kept the rule's inline text as a library prompt without the +-- registry. The path now falls through to the single-page front end and answers +-- 200 text/html, so a client that follows the instruction reads an HTML page +-- where it was promised Markdown and reports that it cannot read the rules. +-- +-- Only that block goes. It is recognised by shape, not by its words: a fenced +-- paragraph that names the removed route, the paragraphs directly after it that +-- still mention REMOTE_ROOT (the file list and the fallback note), and the one +-- paragraph directly before it when that paragraph is the short fetch +-- instruction. A prompt whose route reference sits anywhere else, for example +-- inside a long paragraph, or whose body would be empty without the block, is +-- left as stored and reported with a notice. Every other paragraph, the order +-- of the library, the enabled switch, the default prompt and every other +-- setting are not touched; a library without the route is not written. +-- +-- Running the file again is a no-op. A missing, empty or unparsable +-- system_prompts row is left alone. +-- No explicit BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +DO $strip_remote_bootstrap$ +DECLARE + dead_path CONSTANT TEXT := '/skills/security-research/current'; + stored TEXT; + config JSONB; + item JSONB; + out_items JSONB := '[]'::JSONB; + body TEXT; + new_body TEXT; + paras TEXT[]; + total INT; + anchor INT; + cut_from INT; + cut_to INT; + idx INT; + changed BOOLEAN := FALSE; + remaining INT := 0; +BEGIN + SELECT value INTO stored FROM settings WHERE key = 'system_prompts'; + IF stored IS NULL OR btrim(stored) = '' THEN + RETURN; + END IF; + BEGIN + config := stored::JSONB; + EXCEPTION WHEN others THEN + RAISE NOTICE '269: system_prompts is not valid JSON, left unchanged'; + RETURN; + END; + IF jsonb_typeof(config) IS DISTINCT FROM 'object' + OR jsonb_typeof(config->'prompts') IS DISTINCT FROM 'array' THEN + RETURN; + END IF; + + FOR item IN SELECT elem FROM jsonb_array_elements(config->'prompts') AS elem LOOP + IF jsonb_typeof(item) = 'object' + AND jsonb_typeof(item->'body') = 'string' + AND strpos(item->>'body', dead_path) > 0 THEN + body := item->>'body'; + LOOP + paras := string_to_array(body, E'\n\n'); + total := COALESCE(array_length(paras, 1), 0); + anchor := 0; + FOR idx IN 1..total LOOP + IF strpos(paras[idx], dead_path) > 0 THEN + anchor := idx; + EXIT; + END IF; + END LOOP; + EXIT WHEN anchor = 0; + + IF left(paras[anchor], 3) <> '```' OR right(paras[anchor], 3) <> '```' + OR length(paras[anchor]) > 500 THEN + RAISE NOTICE '269: prompt % keeps a route reference outside a fenced block', item->>'id'; + EXIT; + END IF; + + cut_from := anchor; + IF anchor > 1 AND length(paras[anchor - 1]) <= 400 + AND paras[anchor - 1] ILIKE '%fetch%' AND paras[anchor - 1] ILIKE '%cloud%' THEN + cut_from := anchor - 1; + END IF; + cut_to := anchor; + WHILE cut_to < total + AND length(paras[cut_to + 1]) <= 600 + AND (strpos(paras[cut_to + 1], 'REMOTE_ROOT') > 0 + OR strpos(paras[cut_to + 1], dead_path) > 0) LOOP + cut_to := cut_to + 1; + END LOOP; + + new_body := array_to_string(paras[1:cut_from - 1] || paras[cut_to + 1:total], E'\n\n'); + IF btrim(new_body) = '' THEN + RAISE NOTICE '269: prompt % would be empty without the block, left as stored', item->>'id'; + EXIT; + END IF; + body := new_body; + END LOOP; + + IF body IS DISTINCT FROM item->>'body' THEN + item := jsonb_set(item, '{body}', to_jsonb(body)); + changed := TRUE; + END IF; + IF strpos(body, dead_path) > 0 THEN + remaining := remaining + 1; + END IF; + END IF; + out_items := out_items || jsonb_build_array(item); + END LOOP; + + IF changed THEN + UPDATE settings + SET value = jsonb_set(config, '{prompts}', out_items)::TEXT, updated_at = NOW() + WHERE key = 'system_prompts'; + END IF; + RAISE NOTICE '269: system_prompts changed=%, prompts still naming the removed route=%', changed, remaining; +END; +$strip_remote_bootstrap$;