dqy08 commited on
Commit
c911b05
·
1 Parent(s): c4753aa

DAG增加螺旋模式,支持teacher forcing

Browse files
backend/access_log.py CHANGED
@@ -52,6 +52,20 @@ def _log_request(event_type: str, details: str = "", client_ip: str = None):
52
  print(log_msg)
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def log_page_load(path: str):
56
  from backend.visit_stats import record_page_load
57
 
@@ -89,7 +103,8 @@ def log_analyze_request(text: str, stream_mode: bool = False, client_ip: str = N
89
  request_id = _request_counter
90
 
91
  preview_length = 100
92
- text_preview = text[:preview_length] + '......' if text and len(text) > preview_length else (text if text else '')
 
93
  char_count = len(text) if text else 0
94
  byte_count = len(text.encode('utf-8')) if text else 0
95
  mode_str = "(stream)" if stream_mode else ""
@@ -150,8 +165,8 @@ def log_analyze_semantic_request(query: str, text: str, client_ip: str = None):
150
  request_id = _request_counter
151
 
152
  preview = 50
153
- q_preview = query[:preview] + "..." if len(query) > preview else query
154
- t_preview = text[:preview] + "..." if len(text) > preview else text
155
  details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
156
  _log_request("📥 semantic 分析请求", details, client_ip)
157
 
@@ -183,7 +198,7 @@ def log_openai_completions_request(
183
  request_id = _request_counter
184
 
185
  preview = 100
186
- p_preview = prompt[:preview] + "..." if len(prompt) > preview else prompt
187
  details = (
188
  f"req_id={request_id}, model='{model}', "
189
  f"prompt='{p_preview}', chars={len(prompt)}"
@@ -196,6 +211,7 @@ def log_openai_completions_request(
196
  def log_prediction_attribute_request(
197
  context: str,
198
  target_prediction: Optional[str],
 
199
  model: str,
200
  client_ip: str = None,
201
  ) -> int:
@@ -212,12 +228,11 @@ def log_prediction_attribute_request(
212
  request_id = _request_counter
213
 
214
  context_preview = 150
215
- c_preview = (
216
- context[:context_preview] + "..."
217
- if len(context) > context_preview
218
- else context
219
- )
220
- target_show = "<top-1>" if target_prediction is None else target_prediction
221
  details = (
222
  f"req_id={request_id}, model={model!r}, context='{c_preview}', target='{target_show}', "
223
  f"context_chars={len(context)}"
@@ -237,13 +252,10 @@ def log_openai_completions_prompt_request(
237
  """记录 POST /v1/completions/prompt(仅拼装 chat template,不分配 req_id)。"""
238
  preview = 50
239
 
240
- def _pv(s: str) -> str:
241
- return s[:preview] + "..." if len(s) > preview else s
242
-
243
- up = _pv(user_prompt)
244
  if system is None:
245
  details = f"model='{model}', user_prompt='{up}'"
246
  else:
247
- details = f"model='{model}', system='{_pv(system)}', user_prompt='{up}'"
248
  _log_request("📥 openai completions/prompt 请求", details, client_ip)
249
 
 
52
  print(log_msg)
53
 
54
 
55
+ def _log_str_preview(s: str, max_visible: int) -> str:
56
+ """
57
+ 访问日志中的字符串预览:超过 max_visible 时省略中间,前后各保留约一半原文,
58
+ 中间统一为 ……(与旧版「仅前缀」使用相同的 max_visible 取值)。
59
+ """
60
+ if max_visible < 1:
61
+ return s
62
+ if len(s) <= max_visible:
63
+ return s
64
+ head = max_visible // 2
65
+ tail = max_visible - head
66
+ return s[:head] + "……" + s[-tail:]
67
+
68
+
69
  def log_page_load(path: str):
70
  from backend.visit_stats import record_page_load
71
 
 
103
  request_id = _request_counter
104
 
105
  preview_length = 100
106
+ raw = text if text else ""
107
+ text_preview = _log_str_preview(raw, preview_length)
108
  char_count = len(text) if text else 0
109
  byte_count = len(text.encode('utf-8')) if text else 0
110
  mode_str = "(stream)" if stream_mode else ""
 
165
  request_id = _request_counter
166
 
167
  preview = 50
168
+ q_preview = _log_str_preview(query, preview)
169
+ t_preview = _log_str_preview(text, preview)
170
  details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
171
  _log_request("📥 semantic 分析请求", details, client_ip)
172
 
 
198
  request_id = _request_counter
199
 
200
  preview = 100
201
+ p_preview = _log_str_preview(prompt, preview)
202
  details = (
203
  f"req_id={request_id}, model='{model}', "
204
  f"prompt='{p_preview}', chars={len(prompt)}"
 
211
  def log_prediction_attribute_request(
212
  context: str,
213
  target_prediction: Optional[str],
214
+ target_token_id: Optional[int],
215
  model: str,
216
  client_ip: str = None,
217
  ) -> int:
 
228
  request_id = _request_counter
229
 
230
  context_preview = 150
231
+ c_preview = _log_str_preview(context, context_preview)
232
+ if target_token_id is not None:
233
+ target_show = f"<token_id:{target_token_id}>"
234
+ else:
235
+ target_show = "<top-1>" if target_prediction is None else target_prediction
 
236
  details = (
237
  f"req_id={request_id}, model={model!r}, context='{c_preview}', target='{target_show}', "
238
  f"context_chars={len(context)}"
 
252
  """记录 POST /v1/completions/prompt(仅拼装 chat template,不分配 req_id)。"""
253
  preview = 50
254
 
255
+ up = _log_str_preview(user_prompt, preview)
 
 
 
256
  if system is None:
257
  details = f"model='{model}', user_prompt='{up}'"
258
  else:
259
+ details = f"model='{model}', system='{_log_str_preview(system, preview)}', user_prompt='{up}'"
260
  _log_request("📥 openai completions/prompt 请求", details, client_ip)
261
 
backend/api/openai_completions.py CHANGED
@@ -8,6 +8,7 @@ import traceback
8
  from typing import Any, Callable, Dict, List, Optional, Tuple
9
 
10
  from backend.model_manager import _inference_lock, get_semantic_model_display_name
 
11
  from backend.oom import exit_if_oom, is_oom_error
12
  from backend.completion_generator import (
13
  PromptTooLongError,
@@ -332,7 +333,12 @@ def completions_prompt(completions_prompt_request):
332
  )
333
 
334
  try:
335
- prompt_used = apply_chat_template_for_completion(prompt, system_opt)
 
 
 
 
 
336
  except PromptTooLongError as e:
337
  return {"success": False, "message": str(e)}, 400
338
 
 
8
  from typing import Any, Callable, Dict, List, Optional, Tuple
9
 
10
  from backend.model_manager import _inference_lock, get_semantic_model_display_name
11
+ from backend.prediction_attributor import _slot_for_prediction_attr_model
12
  from backend.oom import exit_if_oom, is_oom_error
13
  from backend.completion_generator import (
14
  PromptTooLongError,
 
333
  )
334
 
335
  try:
336
+ slot = _slot_for_prediction_attr_model(model)
337
+ except ValueError as e:
338
+ return {"success": False, "message": str(e)}, 400
339
+
340
+ try:
341
+ prompt_used = apply_chat_template_for_completion(prompt, system_opt, slot=slot)
342
  except PromptTooLongError as e:
343
  return {"success": False, "message": str(e)}, 400
344
 
backend/api/prediction_attribute.py CHANGED
@@ -14,13 +14,17 @@ def prediction_attribute(attribution_request):
14
  对上下文文本的下一 token 预测做归因分析。
15
 
16
  Args:
17
- attribution_request: 含 context 和 target_prediction 的字典
 
 
 
18
 
19
  Returns:
20
  (响应字典, 状态码) 元组
21
  """
22
  context = attribution_request.get("context")
23
  target_prediction = attribution_request.get("target_prediction")
 
24
  model = attribution_request.get("model")
25
 
26
  if context is None:
@@ -34,6 +38,12 @@ def prediction_attribute(attribution_request):
34
  return {"success": False, "message": "target_prediction must be a string"}, 400
35
  if target_prediction == "":
36
  return {"success": False, "message": "target_prediction must not be empty"}, 400
 
 
 
 
 
 
37
 
38
  if model is None:
39
  return {"success": False, "message": "Missing required field: model"}, 400
@@ -44,7 +54,13 @@ def prediction_attribute(attribution_request):
44
 
45
  client_ip = get_client_ip()
46
  start_time = time.perf_counter()
47
- request_id = log_prediction_attribute_request(context, target_prediction, model, client_ip)
 
 
 
 
 
 
48
 
49
  lock_acquired = _inference_lock.acquire(timeout=LOCK_WAIT_TIMEOUT)
50
  if not lock_acquired:
@@ -57,7 +73,12 @@ def prediction_attribute(attribution_request):
57
  }, 503
58
 
59
  try:
60
- result = analyze_prediction_attribution(context, target_prediction, model=model)
 
 
 
 
 
61
  except ValueError as e:
62
  return {"success": False, "message": str(e)}, 400
63
  except Exception as e:
 
14
  对上下文文本的下一 token 预测做归因分析。
15
 
16
  Args:
17
+ attribution_request: ``context``、``model``。归因目标二选一:
18
+ 省略 ``target_prediction`` 且省略 ``target_token_id`` 时为 top-1;
19
+ 或传非空 ``target_prediction``(字符串首 token);
20
+ 或传 ``target_token_id``(非负整数词表 id);二者不可同时出现。
21
 
22
  Returns:
23
  (响应字典, 状态码) 元组
24
  """
25
  context = attribution_request.get("context")
26
  target_prediction = attribution_request.get("target_prediction")
27
+ target_token_id = attribution_request.get("target_token_id")
28
  model = attribution_request.get("model")
29
 
30
  if context is None:
 
38
  return {"success": False, "message": "target_prediction must be a string"}, 400
39
  if target_prediction == "":
40
  return {"success": False, "message": "target_prediction must not be empty"}, 400
41
+ if target_token_id is not None and not isinstance(target_token_id, int):
42
+ return {"success": False, "message": "target_token_id must be an integer"}, 400
43
+ if target_token_id is not None and target_token_id < 0:
44
+ return {"success": False, "message": "target_token_id must be >= 0"}, 400
45
+ if target_prediction is not None and target_token_id is not None:
46
+ return {"success": False, "message": "target_prediction and target_token_id are mutually exclusive"}, 400
47
 
48
  if model is None:
49
  return {"success": False, "message": "Missing required field: model"}, 400
 
54
 
55
  client_ip = get_client_ip()
56
  start_time = time.perf_counter()
57
+ request_id = log_prediction_attribute_request(
58
+ context=context,
59
+ target_prediction=target_prediction,
60
+ target_token_id=target_token_id,
61
+ model=model,
62
+ client_ip=client_ip,
63
+ )
64
 
65
  lock_acquired = _inference_lock.acquire(timeout=LOCK_WAIT_TIMEOUT)
66
  if not lock_acquired:
 
73
  }, 503
74
 
75
  try:
76
+ result = analyze_prediction_attribution(
77
+ context,
78
+ target_prediction,
79
+ model=model,
80
+ target_token_id=target_token_id,
81
+ )
82
  except ValueError as e:
83
  return {"success": False, "message": str(e)}, 400
84
  except Exception as e:
backend/api/tokenize.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文本 tokenize API:不做模型推理,仅返回各 token 的字符 offset 与原文。"""
2
+ from backend.prediction_attributor import _slot_for_prediction_attr_model
3
+ from backend.model_manager import ensure_slot_weights_loaded
4
+
5
+
6
+ def tokenize(tokenize_request):
7
+ """
8
+ 对 context 用指定 model 的 tokenizer 分词,返回各 token 的字符 offset 与原文。
9
+ 不持有推理锁,不做前向 / 梯度计算。
10
+ """
11
+ context = tokenize_request.get("context")
12
+ model = tokenize_request.get("model")
13
+
14
+ if context is None or not isinstance(context, str) or context == "":
15
+ return {"success": False, "message": "Missing required field: context"}, 400
16
+ if model is None or not isinstance(model, str):
17
+ return {"success": False, "message": "Missing required field: model"}, 400
18
+
19
+ try:
20
+ slot = _slot_for_prediction_attr_model(model)
21
+ except ValueError as e:
22
+ return {"success": False, "message": str(e)}, 400
23
+
24
+ tokenizer, _, _ = ensure_slot_weights_loaded(slot)
25
+
26
+ enc = tokenizer(context, return_offsets_mapping=True)
27
+ token_ids = enc["input_ids"]
28
+ if token_ids and isinstance(token_ids[0], list):
29
+ token_ids = token_ids[0]
30
+ spans = [
31
+ {"offset": [s, e], "raw": context[s:e], "token_id": int(tid)}
32
+ for (s, e), tid in zip(enc["offset_mapping"], token_ids)
33
+ if s < e # 过滤 BOS/EOS 等长度为 0 的特殊 token
34
+ ]
35
+
36
+ return {"success": True, "spans": spans}, 200
backend/completion_generator.py CHANGED
@@ -19,7 +19,7 @@ from transformers import StoppingCriteria, StoppingCriteriaList, TextStreamer
19
  from backend.api.utils import round_to_sig_figs
20
  from backend.app_context import get_verbose
21
  from backend.device import DeviceManager
22
- from backend.model_manager import ensure_semantic_slot_ready
23
  from backend.pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.runtime_config import DEFAULT_TOPK
25
 
@@ -517,15 +517,17 @@ def core_generate_from_text(
517
  def apply_chat_template_for_completion(
518
  user_content: str,
519
  system: Optional[str] = None,
 
 
520
  ) -> str:
521
  """
522
  将单条 user 文本套用到 tokenizer chat template,返回实际送入 core_generate_from_text 的字符串。
523
 
524
  调用方未传入 ``system``(即 ``None``)时仅拼装单条 user 消息;传入字符串时(含 ``\"\"``、仅空白)
525
  原样作为 chat template 的 system 段,不做裁剪或改写。长度与上下文上限由 ``core_generate_from_text``
526
- 在生成前校验。
527
  """
528
- tokenizer, _, _ = ensure_semantic_slot_ready()
529
  if system is None:
530
  messages = [{"role": "user", "content": user_content}]
531
  else:
 
19
  from backend.api.utils import round_to_sig_figs
20
  from backend.app_context import get_verbose
21
  from backend.device import DeviceManager
22
+ from backend.model_manager import ModelSlot, ensure_semantic_slot_ready, ensure_slot_weights_loaded
23
  from backend.pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.runtime_config import DEFAULT_TOPK
25
 
 
517
  def apply_chat_template_for_completion(
518
  user_content: str,
519
  system: Optional[str] = None,
520
+ *,
521
+ slot: ModelSlot = ModelSlot.SEMANTIC,
522
  ) -> str:
523
  """
524
  将单条 user 文本套用到 tokenizer chat template,返回实际送入 core_generate_from_text 的字符串。
525
 
526
  调用方未传入 ``system``(即 ``None``)时仅拼装单条 user 消息;传入字符串时(含 ``\"\"``、仅空白)
527
  原样作为 chat template 的 system 段,不做裁剪或改写。长度与上下文上限由 ``core_generate_from_text``
528
+ 在生成前校验。slot 控制使用哪个槽位的 tokenizer(base 传 ModelSlot.MAIN)。
529
  """
530
+ tokenizer, _, _ = ensure_slot_weights_loaded(slot)
531
  if system is None:
532
  messages = [{"role": "user", "content": user_content}]
533
  else:
backend/prediction_attributor.py CHANGED
@@ -51,7 +51,11 @@ def _slot_for_prediction_attr_model(model: str) -> ModelSlot:
51
 
52
 
53
  def analyze_prediction_attribution(
54
- context: str, target_prediction: Optional[str] = None, *, model: str
 
 
 
 
55
  ) -> Dict:
56
  """
57
  计算 context 中各 token 对 target_prediction 首 token 预测的归因分。
@@ -59,7 +63,8 @@ def analyze_prediction_attribution(
59
  Args:
60
  context: 输入上下文文本(token 数不得超过 ATTRIBUTION_MAX_TOKEN_LENGTH,否则抛 ValueError)
61
  target_prediction: 目标预测文本;tokenize 后取第一个 token 作为归因目标。
62
- 省略或传 None 时自动使top-1(贪心解码)
 
63
  model: ``base`` 为主槽位权重,``instruct`` 为语义槽位权重(与 API 请求体一致)
64
 
65
  Returns:
@@ -78,8 +83,12 @@ def analyze_prediction_attribution(
78
  get_main_model_display_name() if slot == ModelSlot.MAIN else get_semantic_model_display_name()
79
  )
80
 
81
- # 归因目标 id 仅在前向得到 logits 后解析:top-1 argmax;显式 target 用 encode(可与 argmax 不同)。
82
- use_top1 = target_prediction is None
 
 
 
 
83
 
84
  # 对 context 编码,保留 offset_mapping 用于还原字符位置
85
  enc = tokenizer(context, return_tensors="pt", return_offsets_mapping=True)
@@ -121,6 +130,12 @@ def analyze_prediction_attribution(
121
  if use_top1:
122
  target_token_id = int(topk_ids[0].item())
123
  target_token = tokenizer.decode([target_token_id])
 
 
 
 
 
 
124
  else:
125
  assert target_prediction is not None
126
  target_ids = tokenizer.encode(target_prediction, add_special_tokens=False)
@@ -129,10 +144,11 @@ def analyze_prediction_attribution(
129
  target_token_id = target_ids[0]
130
  target_token = tokenizer.decode([target_token_id])
131
 
132
- target_prob = round_to_sig_figs(probs[target_token_id].item())
 
133
 
134
  # 对目标 token 的 raw logit 反传(不经 softmax,避免饱和与竞争污染)
135
- logits[target_token_id].backward()
136
 
137
  grad = embeds.grad
138
  if grad is None:
@@ -168,7 +184,7 @@ def analyze_prediction_attribution(
168
  print(f"⚠️ token_attribution 中有 {nan_count} 个 score 为 NaN/Inf,已替换为 0。")
169
 
170
  eos_id = tokenizer.eos_token_id
171
- is_eos = eos_id is not None and target_token_id == int(eos_id)
172
 
173
  return {
174
  "model": model_display,
 
51
 
52
 
53
  def analyze_prediction_attribution(
54
+ context: str,
55
+ target_prediction: Optional[str] = None,
56
+ *,
57
+ model: str,
58
+ target_token_id: Optional[int] = None,
59
  ) -> Dict:
60
  """
61
  计算 context 中各 token 对 target_prediction 首 token 预测的归因分。
 
63
  Args:
64
  context: 输入上下文文本(token 数不得超过 ATTRIBUTION_MAX_TOKEN_LENGTH,否则抛 ValueError)
65
  target_prediction: 目标预测文本;tokenize 后取第一个 token 作为归因目标。
66
+ target_token_id: 目标 token id; teacher forcing 按 tokenizer 词表精确指定目标
67
+ target_prediction 与 target_token_id 仅可二选一;两者均省略时自动使用 top-1(贪心解码)。
68
  model: ``base`` 为主槽位权重,``instruct`` 为语义槽位权重(与 API 请求体一致)
69
 
70
  Returns:
 
83
  get_main_model_display_name() if slot == ModelSlot.MAIN else get_semantic_model_display_name()
84
  )
85
 
86
+ if target_prediction is not None and target_token_id is not None:
87
+ raise ValueError("target_prediction and target_token_id are mutually exclusive")
88
+
89
+ # 归因目标 id 仅在前向得到 logits 后解析:
90
+ # top-1 用 argmax;显式 target 用 encode;显式 token id 直接使用请求值。
91
+ use_top1 = target_prediction is None and target_token_id is None
92
 
93
  # 对 context 编码,保留 offset_mapping 用于还原字符位置
94
  enc = tokenizer(context, return_tensors="pt", return_offsets_mapping=True)
 
130
  if use_top1:
131
  target_token_id = int(topk_ids[0].item())
132
  target_token = tokenizer.decode([target_token_id])
133
+ elif target_token_id is not None:
134
+ if target_token_id < 0 or target_token_id >= logits.shape[-1]:
135
+ raise ValueError(
136
+ f"target_token_id out of range: {target_token_id} (vocab_size={int(logits.shape[-1])})"
137
+ )
138
+ target_token = tokenizer.decode([int(target_token_id)])
139
  else:
140
  assert target_prediction is not None
141
  target_ids = tokenizer.encode(target_prediction, add_special_tokens=False)
 
144
  target_token_id = target_ids[0]
145
  target_token = tokenizer.decode([target_token_id])
146
 
147
+ assert target_token_id is not None
148
+ target_prob = round_to_sig_figs(probs[int(target_token_id)].item())
149
 
150
  # 对目标 token 的 raw logit 反传(不经 softmax,避免饱和与竞争污染)
151
+ logits[int(target_token_id)].backward()
152
 
153
  grad = embeds.grad
154
  if grad is None:
 
184
  print(f"⚠️ token_attribution 中有 {nan_count} 个 score 为 NaN/Inf,已替换为 0。")
185
 
186
  eos_id = tokenizer.eos_token_id
187
+ is_eos = eos_id is not None and int(target_token_id) == int(eos_id)
188
 
189
  return {
190
  "model": model_display,
client/src/css/attribution.scss CHANGED
@@ -104,10 +104,6 @@
104
  .attribution-exclude-prompt-patterns-header {
105
  flex-wrap: wrap;
106
 
107
- .semantic-submode-label {
108
- font-size: inherit;
109
- }
110
-
111
  &:not(:has(#attribution_exclude_prompt_patterns_enable:checked)) .semantic-submode-label {
112
  color: var(--text-muted);
113
  }
 
104
  .attribution-exclude-prompt-patterns-header {
105
  flex-wrap: wrap;
106
 
 
 
 
 
107
  &:not(:has(#attribution_exclude_prompt_patterns_enable:checked)) .semantic-submode-label {
108
  color: var(--text-muted);
109
  }
client/src/css/gen_attribute.scss CHANGED
@@ -140,9 +140,9 @@
140
  }
141
  }
142
 
143
- // linear-arc 布局禁拖:不显示 grab(由 .gen-attr-dag-stack 上的 class 标记)
144
  #results.gen-attr-results-surface.LMF
145
- .gen-attr-dag-stack.gen-attr-dag-linear-arc-layout
146
  .gen-attr-dag-svg
147
  .gen-attr-dag-node--selected {
148
  cursor: default;
@@ -305,21 +305,92 @@
305
  height: 50px;
306
  }
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  // 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
309
  .attribution-exclude-prompt-patterns-row {
310
  margin-top: 10px;
311
  display: flex;
312
  flex-direction: column;
313
  gap: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
 
316
  .attribution-exclude-prompt-patterns-header {
317
  flex-wrap: wrap;
318
 
319
- .semantic-submode-label {
320
- font-size: inherit;
321
- }
322
-
323
  &:not(:has(#gen_attr_exclude_prompt_patterns_enable:checked)) .semantic-submode-label {
324
  color: var(--text-muted);
325
  }
@@ -332,10 +403,6 @@
332
  .attribution-exclude-generated-patterns-header {
333
  flex-wrap: wrap;
334
 
335
- .semantic-submode-label {
336
- font-size: inherit;
337
- }
338
-
339
  &:not(:has(#gen_attr_exclude_generated_patterns_enable:checked)) .semantic-submode-label {
340
  color: var(--text-muted);
341
  }
@@ -353,6 +420,18 @@
353
 
354
  .gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
355
  margin-right: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
356
  }
357
 
358
  .gen-attr-dag-replay-speed-row {
 
140
  }
141
  }
142
 
143
+ // 非 text-flow 布局禁拖:不显示 grab(由 .gen-attr-dag-stack 上的 class 标记)
144
  #results.gen-attr-results-surface.LMF
145
+ .gen-attr-dag-stack.gen-attr-dag-no-node-drag-layout
146
  .gen-attr-dag-svg
147
  .gen-attr-dag-node--selected {
148
  cursor: default;
 
305
  height: 50px;
306
  }
307
 
308
+ // Raw / User / Forced continuation:统一高度(不占 chat 默认 250px 主 prompt 区)
309
+ .input-section .textarea-wrapper textarea#gen_attr_raw_text,
310
+ .input-section .textarea-wrapper textarea#gen_attr_user_text,
311
+ .input-section .textarea-wrapper textarea#gen_attr_teacher_forcing_text {
312
+ height: 90px;
313
+ min-height: 60px;
314
+ max-height: 250px;
315
+ }
316
+
317
+ body.gen-attribute-page .input-section {
318
+ span.semantic-submode-label {
319
+ color: var(--text-muted);
320
+ }
321
+
322
+ .semantic-submode-row:not(.attribution-exclude-prompt-patterns-header):not(.attribution-exclude-generated-patterns-header) {
323
+ label.semantic-submode-label {
324
+ color: var(--text-primary);
325
+ }
326
+
327
+ label.semantic-submode-label:has(> input[type='checkbox']:not(:checked)) {
328
+ color: var(--text-muted);
329
+ }
330
+ }
331
+
332
+ // Start 上方的 prompt 区标题与同系字号(非 .semantic-submode-row 后代时补齐 9pt)
333
+ > .semantic-submode-row.chat-raw-prompt-mode-row label.semantic-submode-label {
334
+ display: inline-flex;
335
+ align-items: center;
336
+ gap: 6px;
337
+ cursor: pointer;
338
+ user-select: none;
339
+ }
340
+
341
+ .chat-prompt-panel > .input-header > .semantic-submode-label {
342
+ font-size: 9pt;
343
+ }
344
+
345
+ // System:在 input-header 内(非 semantic-submode-row),勾选主次色与勾选行一致
346
+ #gen_attr_system_prompt_panel.chat-prompt-panel > .input-header label.chat-use-system-label.semantic-submode-label {
347
+ font-size: 9pt;
348
+
349
+ &:has(> input[type='checkbox']:not(:checked)) {
350
+ color: var(--text-muted);
351
+ }
352
+
353
+ &:has(> input[type='checkbox']:checked) {
354
+ color: var(--text-primary);
355
+ }
356
+ }
357
+ }
358
+
359
  // 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
360
  .attribution-exclude-prompt-patterns-row {
361
  margin-top: 10px;
362
  display: flex;
363
  flex-direction: column;
364
  gap: 4px;
365
+
366
+ // Teacher forcing:与同页 Chat 模板的相邻面板间距(12px,见 chat.scss `#chat_input_panel`)对齐;容器默认 gap 仅 4px 会显得挤。
367
+ &:has(.gen-attr-teacher-forcing-toggle-row) {
368
+ gap: 12px;
369
+ }
370
+ }
371
+
372
+ // Teacher forcing / Stop after:勾选行与同页 semantic-submode 一致(字号、未勾选灰色);勾选+文本 inline-flex 对齐
373
+ .gen-attr-teacher-forcing-toggle-row label.semantic-submode-label,
374
+ #gen_attr_teacher_forcing_block.chat-prompt-panel .gen-attr-stop-after-tf-row label.semantic-submode-label {
375
+ display: inline-flex;
376
+ align-items: center;
377
+ gap: 6px;
378
+ cursor: pointer;
379
+ user-select: none;
380
+ }
381
+
382
+ // 强制续写块内 textarea 与「Stop after teacher forcing」;整块与下方 Model / Start 行的节奏(比照本列其它块 margin-top 10px)
383
+ #gen_attr_teacher_forcing_block.chat-prompt-panel .gen-attr-stop-after-tf-row {
384
+ margin-top: 8px;
385
+ }
386
+
387
+ .gen-attribute-page .input-section > .textarea-wrapper.chat-prompt-actions-row {
388
+ margin-top: 10px;
389
  }
390
 
391
  .attribution-exclude-prompt-patterns-header {
392
  flex-wrap: wrap;
393
 
 
 
 
 
394
  &:not(:has(#gen_attr_exclude_prompt_patterns_enable:checked)) .semantic-submode-label {
395
  color: var(--text-muted);
396
  }
 
403
  .attribution-exclude-generated-patterns-header {
404
  flex-wrap: wrap;
405
 
 
 
 
 
406
  &:not(:has(#gen_attr_exclude_generated_patterns_enable:checked)) .semantic-submode-label {
407
  color: var(--text-muted);
408
  }
 
420
 
421
  .gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
422
  margin-right: 12px;
423
+
424
+ .semantic-submode-label,
425
+ .gen-attr-dag-layout-mode-select {
426
+ font-weight: 700;
427
+ }
428
+ }
429
+
430
+ .gen-attr-dag-measure-width-row .gen-attr-dag-replay-speed-row {
431
+ > .semantic-submode-label,
432
+ .gen-attr-dag-replay-mode-select {
433
+ font-weight: 700;
434
+ }
435
  }
436
 
437
  .gen-attr-dag-replay-speed-row {
client/src/gen_attribute.html CHANGED
@@ -43,7 +43,7 @@
43
  <section class="input-section">
44
  <div class="semantic-submode-row chat-raw-prompt-mode-row">
45
  <span class="semantic-submode-group">
46
- <label for="gen_attr_skip_chat_template">
47
  <input type="checkbox" id="gen_attr_skip_chat_template" />
48
  <span data-i18n>Raw prompt mode</span>
49
  </label>
@@ -52,7 +52,7 @@
52
 
53
  <div id="gen_attr_raw_input_panel" class="chat-prompt-panel">
54
  <div class="input-header">
55
- <span><span class="demo" data-i18n>Raw prompt</span></span>
56
  <div class="text-action-buttons-top">
57
  <div class="textarea-counter" id="gen_attr_raw_text_count_display">
58
  <span id="gen_attr_raw_text_count_value">0</span> <span data-i18n>chars</span>
@@ -73,9 +73,9 @@
73
  <div id="gen_attr_chat_input_panel" hidden>
74
  <div class="chat-prompt-panel" id="gen_attr_system_prompt_panel">
75
  <div class="input-header">
76
- <label class="chat-use-system-label">
77
  <input type="checkbox" id="gen_attr_use_system_prompt" checked />
78
- <span class="demo" data-i18n>System</span>
79
  </label>
80
  <div class="text-action-buttons-top">
81
  <div class="textarea-counter" id="gen_attr_system_text_count_display">
@@ -95,7 +95,7 @@
95
  </div>
96
  <div class="chat-prompt-panel">
97
  <div class="input-header">
98
- <span><span class="demo" data-i18n>User</span></span>
99
  <div class="text-action-buttons-top">
100
  <div class="textarea-counter" id="gen_attr_user_text_count_display">
101
  <span id="gen_attr_user_text_count_value">0</span> <span data-i18n>chars</span>
@@ -114,6 +114,49 @@
114
  </div>
115
  </div>
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  <div class="textarea-wrapper chat-prompt-actions-row">
118
  <div class="semantic-submode-row chat-completion-options-row attribution-model-variant-row">
119
  <span class="semantic-submode-group">
@@ -146,6 +189,61 @@
146
  </div>
147
  </div>
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  <div class="attribution-exclude-prompt-patterns-row">
150
  <div class="semantic-submode-row attribution-exclude-prompt-patterns-header">
151
  <span class="semantic-submode-group">
@@ -200,60 +298,6 @@
200
  </label>
201
  </span>
202
  </div>
203
- <div class="gen-attr-dag-measure-width-row semantic-submode-row">
204
- <span class="semantic-submode-group">
205
- <label class="semantic-submode-label">
206
- <input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
207
- title="When checked, gray DAG edges not adjacent to the hovered or selected node are hidden."
208
- data-i18n="title">
209
- Hide inactive edges
210
- </label>
211
- </span>
212
- </div>
213
- <div class="gen-attr-dag-measure-width-row semantic-submode-row">
214
- <span class="semantic-submode-group">
215
- <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
216
- <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
217
- value="0.7" min="0.05" max="1" step="0.05"
218
- title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
219
- data-i18n="title">
220
- </span>
221
- </div>
222
- <div class="gen-attr-dag-measure-width-row semantic-submode-row">
223
- <span class="semantic-submode-group gen-attr-dag-layout-mode-group">
224
- <label class="semantic-submode-label" for="gen_attr_dag_layout_mode">DAG layout mode</label>
225
- <select id="gen_attr_dag_layout_mode"
226
- class="semantic-submode-select gen-attr-dag-layout-mode-select"
227
- title="Choose DAG layout mode. 'text-flow' follows text layout geometry; 'linear-arc' uses fixed-order linear nodes with arc links."
228
- data-i18n="title">
229
- <option value="text-flow">text-flow</option>
230
- <option value="linear-arc">linear-arc</option>
231
- </select>
232
- </span>
233
- <span class="semantic-submode-group" id="gen_attr_dag_compactness_group">
234
- <label class="semantic-submode-label" for="gen_attr_dag_compactness" data-i18n>Compactness</label>
235
- <input type="number" id="gen_attr_dag_compactness" class="gen-attr-dag-measure-width-input"
236
- value="0.5" min="0.05" max="1" step="0.05"
237
- title="Scales DAG node boxes and labels relative to the measurement layer; 1 matches full readout scale. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh."
238
- data-i18n="title">
239
- </span>
240
- <span class="semantic-submode-group" id="gen_attr_dag_measure_width_group">
241
- <label class="semantic-submode-label" for="gen_attr_dag_measure_width">Text width</label>
242
- <input type="number" id="gen_attr_dag_measure_width" class="gen-attr-dag-measure-width-input"
243
- value="500" min="200" max="4000" step="10"
244
- title="Width (px) of the invisible measurement layer used for DAG layout. Only this width affects wrapping and node positions. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh."
245
- data-i18n="title">
246
- <span class="semantic-submode-label">px</span>
247
- </span>
248
- <span class="semantic-submode-group" id="gen_attr_dag_linear_arc_interval_group" hidden>
249
- <label class="semantic-submode-label" for="gen_attr_dag_linear_arc_interval" data-i18n>Token distance</label>
250
- <input type="number" id="gen_attr_dag_linear_arc_interval" class="gen-attr-dag-measure-width-input"
251
- value="0" min="0" max="400" step="1"
252
- title="Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync."
253
- data-i18n="title">
254
- <span class="semantic-submode-label">px</span>
255
- </span>
256
- </div>
257
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
258
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
259
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
 
43
  <section class="input-section">
44
  <div class="semantic-submode-row chat-raw-prompt-mode-row">
45
  <span class="semantic-submode-group">
46
+ <label class="semantic-submode-label" for="gen_attr_skip_chat_template">
47
  <input type="checkbox" id="gen_attr_skip_chat_template" />
48
  <span data-i18n>Raw prompt mode</span>
49
  </label>
 
52
 
53
  <div id="gen_attr_raw_input_panel" class="chat-prompt-panel">
54
  <div class="input-header">
55
+ <span class="semantic-submode-label" data-i18n>Raw prompt</span>
56
  <div class="text-action-buttons-top">
57
  <div class="textarea-counter" id="gen_attr_raw_text_count_display">
58
  <span id="gen_attr_raw_text_count_value">0</span> <span data-i18n>chars</span>
 
73
  <div id="gen_attr_chat_input_panel" hidden>
74
  <div class="chat-prompt-panel" id="gen_attr_system_prompt_panel">
75
  <div class="input-header">
76
+ <label class="chat-use-system-label semantic-submode-label" for="gen_attr_use_system_prompt">
77
  <input type="checkbox" id="gen_attr_use_system_prompt" checked />
78
+ <span data-i18n>System</span>
79
  </label>
80
  <div class="text-action-buttons-top">
81
  <div class="textarea-counter" id="gen_attr_system_text_count_display">
 
95
  </div>
96
  <div class="chat-prompt-panel">
97
  <div class="input-header">
98
+ <span class="semantic-submode-label" data-i18n>User</span>
99
  <div class="text-action-buttons-top">
100
  <div class="textarea-counter" id="gen_attr_user_text_count_display">
101
  <span id="gen_attr_user_text_count_value">0</span> <span data-i18n>chars</span>
 
114
  </div>
115
  </div>
116
 
117
+ <div class="attribution-exclude-prompt-patterns-row">
118
+ <div class="semantic-submode-row gen-attr-teacher-forcing-toggle-row">
119
+ <span class="semantic-submode-group">
120
+ <label class="semantic-submode-label"
121
+ title="When enabled, type the exact continuation after the assembled prompt. Each step attributes the next token toward that text (same tokenizer as Model), then stops when the continuation is consumed or EOS."
122
+ data-i18n="title">
123
+ <input type="checkbox" id="gen_attr_teacher_forcing_enable">
124
+ <span data-i18n>Teacher forcing</span>
125
+ </label>
126
+ </span>
127
+ </div>
128
+ <div id="gen_attr_teacher_forcing_block" class="chat-prompt-panel" hidden>
129
+ <div class="input-header">
130
+ <span class="semantic-submode-label" data-i18n>Forced continuation</span>
131
+ <div class="text-action-buttons-top">
132
+ <div class="textarea-counter" id="gen_attr_teacher_forcing_text_count_display">
133
+ <span id="gen_attr_teacher_forcing_text_count_value">0</span> <span data-i18n>chars</span>
134
+ </div>
135
+ <button type="button" id="gen_attr_clear_teacher_forcing_btn" class="text-action-btn" data-i18n>Clear</button>
136
+ <button type="button" id="gen_attr_paste_teacher_forcing_btn" class="text-action-btn" data-i18n>Paste</button>
137
+ <button type="button" id="gen_attr_teacher_forcing_history_btn" class="text-action-btn" data-i18n>History</button>
138
+ </div>
139
+ </div>
140
+ <div class="textarea-wrapper chat-prompt-textarea-block">
141
+ <div class="semantic-search-input-wrapper chat-prompt-history-wrapper">
142
+ <textarea id="gen_attr_teacher_forcing_text"
143
+ spellcheck="false" autocomplete="off"
144
+ title="Expected generated text after the full prompt. Each API step uses the first token of what remains here as the attribution target."
145
+ data-i18n="title"></textarea>
146
+ <ul id="gen_attr_teacher_forcing_history_dropdown" class="semantic-search-history-dropdown"></ul>
147
+ </div>
148
+ </div>
149
+ <div class="semantic-submode-row gen-attr-stop-after-tf-row">
150
+ <label class="semantic-submode-label"
151
+ title="When unchecked, generation continues with top-1 after teacher forcing tokens are exhausted, up to Max tokens."
152
+ data-i18n="title">
153
+ <input type="checkbox" id="gen_attr_stop_after_teacher_forcing">
154
+ <span data-i18n>Stop after teacher forcing</span>
155
+ </label>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
  <div class="textarea-wrapper chat-prompt-actions-row">
161
  <div class="semantic-submode-row chat-completion-options-row attribution-model-variant-row">
162
  <span class="semantic-submode-group">
 
189
  </div>
190
  </div>
191
 
192
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
193
+ <span class="semantic-submode-group gen-attr-dag-layout-mode-group">
194
+ <label class="semantic-submode-label" for="gen_attr_dag_layout_mode">DAG layout mode</label>
195
+ <select id="gen_attr_dag_layout_mode"
196
+ class="semantic-submode-select gen-attr-dag-layout-mode-select"
197
+ title="Choose DAG layout mode. 'text-flow' follows text layout geometry; 'linear-arc' uses fixed-order linear nodes with arc links; 'spiral' lays nodes on an Archimedean spiral (for fun)."
198
+ data-i18n="title">
199
+ <option value="text-flow">text-flow</option>
200
+ <option value="linear-arc">linear-arc</option>
201
+ <option value="spiral">spiral (for fun)</option>
202
+ </select>
203
+ </span>
204
+ <span class="semantic-submode-group" id="gen_attr_dag_compactness_group">
205
+ <label class="semantic-submode-label" for="gen_attr_dag_compactness" data-i18n>Compactness</label>
206
+ <input type="number" id="gen_attr_dag_compactness" class="gen-attr-dag-measure-width-input"
207
+ value="0.5" min="0.05" max="1" step="0.05"
208
+ title="Scales DAG node boxes and labels relative to the measurement layer; 1 matches full readout scale. Applies in text-flow and spiral layouts. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh."
209
+ data-i18n="title">
210
+ </span>
211
+ <span class="semantic-submode-group" id="gen_attr_dag_measure_width_group">
212
+ <label class="semantic-submode-label" for="gen_attr_dag_measure_width">Text width</label>
213
+ <input type="number" id="gen_attr_dag_measure_width" class="gen-attr-dag-measure-width-input"
214
+ value="500" min="200" max="4000" step="10"
215
+ title="Width (px) of the invisible measurement layer used for DAG layout. Only this width affects wrapping and node positions. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh."
216
+ data-i18n="title">
217
+ <span class="semantic-submode-label">px</span>
218
+ </span>
219
+ <span class="semantic-submode-group" id="gen_attr_dag_linear_arc_interval_group" hidden>
220
+ <label class="semantic-submode-label" for="gen_attr_dag_linear_arc_interval" data-i18n>Token distance</label>
221
+ <input type="number" id="gen_attr_dag_linear_arc_interval" class="gen-attr-dag-measure-width-input"
222
+ value="0" min="0" max="400" step="1"
223
+ title="Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync."
224
+ data-i18n="title">
225
+ <span class="semantic-submode-label">px</span>
226
+ </span>
227
+ </div>
228
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
229
+ <span class="semantic-submode-group">
230
+ <label class="semantic-submode-label">
231
+ <input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
232
+ title="When checked, gray DAG edges not adjacent to the hovered or selected node are hidden."
233
+ data-i18n="title">
234
+ Hide inactive edges
235
+ </label>
236
+ </span>
237
+ </div>
238
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
239
+ <span class="semantic-submode-group">
240
+ <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
241
+ <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
242
+ value="0.7" min="0.05" max="1" step="0.05"
243
+ title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
244
+ data-i18n="title">
245
+ </span>
246
+ </div>
247
  <div class="attribution-exclude-prompt-patterns-row">
248
  <div class="semantic-submode-row attribution-exclude-prompt-patterns-header">
249
  <span class="semantic-submode-group">
 
298
  </label>
299
  </span>
300
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
302
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
303
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
client/src/ts/attribution/genAttributeDagEdgeDisplay.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DAG 边最终 `stroke-opacity`(`normalizedScore × mutualInformationRatio`)的下限:
3
+ * 小于该值的边不进入图中展示。
4
+ *
5
+ * 与同数值在 `genAttributeDagPreprocess.ts` 池内前缀选取里 `relativeFloor = 常数 × topFrac` 复用:
6
+ * max 归一后首条 `normalizedScore === 1`,故低于该相对份额的条目不可能在 MI≤1 下达到本阈值,属提前筛除。
7
+ */
8
+ export const DAG_EDGE_MIN_DISPLAY_OPACITY = 0.1;
client/src/ts/attribution/genAttributeDagLinkSegment.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 轴对齐矩形节点:用于连线从边界起止(与 layout 所用的 x,y 左上角一致)。 */
2
+ export type DagLinkRectNode = {
3
+ x: number;
4
+ y: number;
5
+ nodeW: number;
6
+ nodeH: number;
7
+ };
8
+
9
+ function nodeCenter(n: DagLinkRectNode): { cx: number; cy: number } {
10
+ return { cx: n.x + n.nodeW / 2, cy: n.y + n.nodeH / 2 };
11
+ }
12
+
13
+ /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离。 */
14
+ function distCenterToRectEdgeAlongRay(hw: number, hh: number, ux: number, uy: number): number {
15
+ const ax = Math.abs(ux);
16
+ const ay = Math.abs(uy);
17
+ let t = Infinity;
18
+ if (ax > 1e-12) t = Math.min(t, hw / ax);
19
+ if (ay > 1e-12) t = Math.min(t, hh / ay);
20
+ return Number.isFinite(t) ? t : 0;
21
+ }
22
+
23
+ /** 两节点矩形边界之间的线段,端点可再回缩 `outsideInset`(与 text-flow 一致)。 */
24
+ export function linkSegmentThroughNodeRects(
25
+ src: DagLinkRectNode,
26
+ tgt: DagLinkRectNode,
27
+ outsideInset: number
28
+ ): { x1: number; y1: number; x2: number; y2: number } {
29
+ const a = nodeCenter(src);
30
+ const b = nodeCenter(tgt);
31
+ const dx = b.cx - a.cx;
32
+ const dy = b.cy - a.cy;
33
+ const L = Math.hypot(dx, dy);
34
+ if (L < 1e-9) return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
35
+ const ux = dx / L;
36
+ const uy = dy / L;
37
+ const tA = distCenterToRectEdgeAlongRay(src.nodeW / 2, src.nodeH / 2, ux, uy);
38
+ const tB = distCenterToRectEdgeAlongRay(tgt.nodeW / 2, tgt.nodeH / 2, ux, uy);
39
+ const eps = 1e-6;
40
+ let g = outsideInset;
41
+ if (tA + tB + 2 * g >= L - eps) g = 0;
42
+ if (tA + tB + 2 * g >= L - eps) {
43
+ return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
44
+ }
45
+ return {
46
+ x1: a.cx + (tA + g) * ux,
47
+ y1: a.cy + (tA + g) * uy,
48
+ x2: b.cx - (tB + g) * ux,
49
+ y2: b.cy - (tB + g) * uy,
50
+ };
51
+ }
client/src/ts/attribution/genAttributeDagPreprocess.ts CHANGED
@@ -9,11 +9,14 @@ import {
9
  import type { NodeAggregatedEntry } from './genAttributeDagIntervalResolve';
10
  import type { TokenGenStep } from './tokenGenAttributionRunner';
11
  import { getAttentionRawScore } from '../utils/semanticUtils';
 
12
 
13
  /** 与 DAG 节点 id 一致:来自 API `token_attribution` 几何(按 offset 去重,独立于 exclude/归一化)。 */
14
  export type PromptTokenSpan = {
15
  offset: [number, number];
16
  raw: string;
 
 
17
  };
18
 
19
  /** 每步在 exclude 之后按 `score` 降序取前 N 条作为候选池,避免长上下文长尾稀释。 */
@@ -30,10 +33,6 @@ export function clampDagEdgeTopPCoverage(n: number): number {
30
  return Math.min(DAG_EDGE_TOP_P_COVERAGE_MAX, Math.max(DAG_EDGE_TOP_P_COVERAGE_MIN, n));
31
  }
32
 
33
- /** 候选池内相对最强条目的下限系数:池内 L1 份额小于该比例×首条份额时停止。 */
34
- // topShare 的线有最大的透明度,所以这里对应的是最小的透明度是最大透明度的比例
35
- const DAG_EDGE_RELATIVE_TOP_SHARE_FLOOR_BETA = 0.1;
36
-
37
  /**
38
  * 按 `score` 降序排序后取前 min(N, length) 项。
39
  * 会 **原地** `sort` 输入数组(与池内 `poolMassFrac` 次序一致,调用方无需再按份额排序)。
@@ -64,9 +63,14 @@ function normalizeTopNPoolForDagSparse<T extends { score: number }>(tokens: T[])
64
 
65
  /**
66
  * 在候选池已按 `score` 降序、池内归一保持该顺序的前提下,按遍历顺序取前缀,直到:
67
- * - 池内 L1 份额小于 β×首条份额(分布形状截断),或
68
  * - 累计达到给定阈值(默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT};候选池内 Top-P,非整步全量 token 的分母)。
69
  * (池内份额与 `score` 单调一致,无需再排序。)
 
 
 
 
 
70
  */
71
  function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: number }>(
72
  normalized: Array<T>,
@@ -76,7 +80,7 @@ function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: numbe
76
 
77
  const topFrac = normalized[0]?.poolMassFrac ?? 0;
78
  if (!(topFrac > 0)) return [];
79
- const relativeFloor = DAG_EDGE_RELATIVE_TOP_SHARE_FLOOR_BETA * topFrac;
80
 
81
  let cum = 0;
82
  const picked: Array<T> = [];
 
9
  import type { NodeAggregatedEntry } from './genAttributeDagIntervalResolve';
10
  import type { TokenGenStep } from './tokenGenAttributionRunner';
11
  import { getAttentionRawScore } from '../utils/semanticUtils';
12
+ import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
13
 
14
  /** 与 DAG 节点 id 一致:来自 API `token_attribution` 几何(按 offset 去重,独立于 exclude/归一化)。 */
15
  export type PromptTokenSpan = {
16
  offset: [number, number];
17
  raw: string;
18
+ /** tokenizer 词表 id(/api/tokenize 返回);DAG 几何不依赖此字段。 */
19
+ token_id?: number;
20
  };
21
 
22
  /** 每步在 exclude 之后按 `score` 降序取前 N 条作为候选池,避免长上下文长尾稀释。 */
 
33
  return Math.min(DAG_EDGE_TOP_P_COVERAGE_MAX, Math.max(DAG_EDGE_TOP_P_COVERAGE_MIN, n));
34
  }
35
 
 
 
 
 
36
  /**
37
  * 按 `score` 降序排序后取前 min(N, length) 项。
38
  * 会 **原地** `sort` 输入数组(与池内 `poolMassFrac` 次序一致,调用方无需再按份额排序)。
 
63
 
64
  /**
65
  * 在候选池已按 `score` 降序、池内归一保持该顺序的前提下,按遍历顺序取前缀,直到:
66
+ * - 池内 L1 份额小于 {@link DAG_EDGE_MIN_DISPLAY_OPACITY}×首条份额(`relativeFloor`,系数与最小展示透明度同值),或
67
  * - 累计达到给定阈值(默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT};候选池内 Top-P,非整步全量 token 的分母)。
68
  * (池内份额与 `score` 单调一致,无需再排序。)
69
+ *
70
+ * `relativeFloor`:{@link normalizeTopNPoolForDagSparse} 后首条 `normalizedScore === 1`,且对正分条目有
71
+ * `poolMassFrac_i / topFrac === normalizedScore_i`。故 `frac < β×topFrac` ⇔ `normalizedScore < β`;
72
+ * 再乘互信息率(≤1)后不可能达到视图层最小 `stroke-opacity`,等于提前剔除注定画不出的边,与
73
+ * {@link DAG_EDGE_MIN_DISPLAY_OPACITY} 在视图中的含义对齐。
74
  */
75
  function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: number }>(
76
  normalized: Array<T>,
 
80
 
81
  const topFrac = normalized[0]?.poolMassFrac ?? 0;
82
  if (!(topFrac > 0)) return [];
83
+ const relativeFloor = DAG_EDGE_MIN_DISPLAY_OPACITY * topFrac;
84
 
85
  let cum = 0;
86
  const picked: Array<T> = [];
client/src/ts/attribution/genAttributeDagView.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
  phase2RankAndSparsify,
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
 
13
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
14
  import {
15
  alignAndAggregateByNode,
@@ -19,6 +20,7 @@ import {
19
  } from './genAttributeDagIntervalResolve';
20
  import type { TokenGenStep } from './tokenGenAttributionRunner';
21
  import { createGenAttributeDagTextMeasure } from './genAttributeDagTextMeasure';
 
22
  import {
23
  CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT,
24
  dagResultsSurfaceFullscreenExpanded,
@@ -34,13 +36,14 @@ import {
34
  paintLinearArcLayout,
35
  } from './genAttributeDagViewLinearArcMode';
36
  import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
 
37
  import { tr } from '../lang/i18n-lite';
38
 
39
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
40
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
41
 
42
- /** 节点布局模式:`text-flow` 按文字排版层几何;`linear-arc` 按节点插入序线性排布 + 弧线连边。 */
43
- export type DagLayoutMode = 'text-flow' | 'linear-arc';
44
 
45
  export const DAG_COMPACTNESS_DEFAULT = 0.5;
46
  /** 下限取小正数以满足 {@link readDisplayScaleFromCss}「必须为正」且不出现零宽度边线。 */
@@ -52,6 +55,40 @@ export function clampDagCompactness(n: number): number {
52
  return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n));
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export {
56
  clampLinearArcAdjacentGap,
57
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
@@ -89,8 +126,14 @@ type DagNode = DagNodeAttrs;
89
  type DagLink = {
90
  source: string;
91
  target: string;
92
- strength?: number;
93
- /** 本步该边在 Σ(score) L1 份额用于原生 title「Fan in share」 */
 
 
 
 
 
 
94
  scoreShare?: number;
95
  /** 与 `console.warn('[genAttributeDagView.align] …')` 正文一致(可多条,换行拼接) */
96
  alignmentNote?: string;
@@ -98,6 +141,11 @@ type DagLink = {
98
  titleText: string;
99
  };
100
 
 
 
 
 
 
101
  function dagLinkEndpointKey(source: string, target: string): string {
102
  return `${source}->${target}`;
103
  }
@@ -156,8 +204,27 @@ function stackLayoutViewportPx(stackEl: HTMLElement): { w: number; h: number } {
156
  };
157
  }
158
 
159
- /** 在「抵消 display-scale」基准上再放大,作为 DAG 默认初始视图(d3 zoom 的 k) */
160
- const DAG_INITIAL_ZOOM_BOOST = 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-compactness` 一致(display-scale/link 线粗等同源派生) */
163
  const CSS_VAR_DAG_COMPACTNESS = '--gen-attr-dag-compactness';
@@ -179,7 +246,7 @@ const DAG_NODE_WEAKEN_OPACITY = 0.5;
179
  const DAG_NODE_HIDDEN_OPACITY = 0.1;
180
 
181
  /** 暂时关闭节点上的原生 `<title>` 悬浮提示;恢复时改为 `false`(边不受影响) */
182
- const DISABLE_DAG_NODE_TOOLTIPS = true;
183
 
184
  /**
185
  * 边端在矩形边界外侧的留白,相对测量层「1em」的比例(无单位);与箭头/描边衔接用。
@@ -260,7 +327,7 @@ export type GenAttributeDagHandle = {
260
  */
261
  reset(preserveUserViewport?: boolean): void;
262
  /**
263
- * zoom identity 后按内容适配视口;空图走默认缩放;`k` 上限 `k₀`。
264
  * - `text-flow`:`rootG.getBBox()`(含边)等比落入内框。
265
  * - `linear-arc`:仅按 `gen-attr-dag-nodes` 行宽定比,token 行相对内框竖直居中(弧不参与)。
266
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
@@ -298,6 +365,8 @@ export type GenAttributeDagHandle = {
298
  * - `false`(默认):保留为低透明度({@link DAG_NODE_HIDDEN_OPACITY})占位。
299
  */
300
  setHideExcludedTokens(hide: boolean): void;
 
 
301
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
302
  detach(): void;
303
  };
@@ -322,20 +391,38 @@ function formatNodeOffsetRange(id: string): string {
322
  return `[${a}, ${b})`;
323
  }
324
 
325
- function buildNodeNativeTitleText(d: Pick<DagNode, 'displayLabel' | 'id' | 'step'>): string {
326
- return `${d.displayLabel}\nOffset: ${formatNodeOffsetRange(d.id)}\nStep: ${d.step}`;
 
 
 
 
 
 
 
 
 
 
 
 
327
  }
328
 
329
  /** 建边时调用:端点已带 {@link DagNodeAttrs.displayLabel} */
330
  function buildLinkTitleText(
331
- d: Pick<DagLink, 'strength' | 'scoreShare' | 'alignmentNote'>,
332
  src: DagNode,
333
  tgt: DagNode
334
  ): string {
335
- const s = d.strength ?? 1;
336
- const strengthStr = Number.isFinite(s) ? s.toFixed(3) : String(s);
337
-
338
- const metrics = [`Strength: ${strengthStr}`];
 
 
 
 
 
 
339
  const share = d.scoreShare;
340
  if (typeof share === 'number' && Number.isFinite(share) && share > 0) {
341
  metrics.push(`Fan in share: ${(share * 100).toFixed(1)}%`);
@@ -515,6 +602,7 @@ export function initGenAttributeDagView(
515
  setDagCompactness: noop,
516
  setEdgeTopPCoverage: noop,
517
  setHideExcludedTokens: noop,
 
518
  detach: noop,
519
  };
520
  }
@@ -529,9 +617,9 @@ export function initGenAttributeDagView(
529
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
530
  const stackEl = stack.node() as HTMLElement;
531
 
532
- /** linear-arc 下禁用节点拖同时用该类覆盖选中 grab光标。 */
533
  function syncStackLayoutDragUi(): void {
534
- stackEl.classList.toggle('gen-attr-dag-linear-arc-layout', layoutMode === 'linear-arc');
535
  }
536
  syncStackLayoutDragUi();
537
 
@@ -598,14 +686,14 @@ export function initGenAttributeDagView(
598
 
599
  /**
600
  * 基准缩放为 `1 / --gen-attr-dag-display-scale`:节点几何与 SVG 文字已按 display-scale 相对测量层缩放后,
601
- * 再用其倒数做 zoom,使屏上接近未单独缩小时的阅读比例;实际初始 k 还会乘以 `DAG_INITIAL_ZOOM_BOOST`
602
  */
603
  function initialDagZoomK(): number {
604
  return 1 / displayScale;
605
  }
606
 
607
  function defaultDagZoomK(): number {
608
- return initialDagZoomK() * DAG_INITIAL_ZOOM_BOOST;
609
  }
610
 
611
  const zoomBehavior = d3
@@ -659,9 +747,6 @@ export function initGenAttributeDagView(
659
  */
660
  let userDraggedNodes = false;
661
 
662
- const strengthToOpacity = (s: number) => s; // 由于有DAG_EDGE_RELATIVE_TOP_SHARE_FLOOR_BETA的限制,所以这里不再限制透明度
663
- // const strengthToOpacity = (s: number) => 0.1 + s * 0.9;
664
-
665
  let linkSel = rootG
666
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
667
  .data<DagLink>([], dagLinkDataKey);
@@ -689,6 +774,22 @@ export function initGenAttributeDagView(
689
  });
690
  return;
691
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  paintTextFlowLayout({
693
  linkSel,
694
  nodeSel,
@@ -747,14 +848,15 @@ export function initGenAttributeDagView(
747
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
748
  return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY;
749
  }
750
- const isPromptLeaf = d.step === -1 && graph.outDegree(d.id) === 0;
 
751
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
752
  return 1;
753
  });
754
- // 每条边独立 marker:线与箭头 path 同步 stroke / stroke-opacity(强度仍按边独立)
 
755
  linkSel.each(function(d) {
756
- const incidentToFocus = dagLinkIncidentToFocus(graph, focusId, d);
757
- const op = strengthToOpacity(d.strength ?? 1);
758
  const stroke =
759
  dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`;
760
  const g = d3.select(this);
@@ -837,7 +939,7 @@ export function initGenAttributeDagView(
837
  return g;
838
  });
839
  // 不在此处全量重置 marker `stroke-opacity`:紧接着的 {@link refreshNodeLinkHighlight} 会按边
840
- // 逐条写 `strengthToOpacity(d.strength)`,任何前值都会被覆盖,全量重置纯冗余。
841
 
842
  nodeSel = nodeG
843
  .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node')
@@ -994,6 +1096,7 @@ export function initGenAttributeDagView(
994
  displayLabel,
995
  id: targetId,
996
  step: stepProcessed,
 
997
  }),
998
  };
999
  graph.addNode(targetId, targetNode);
@@ -1013,8 +1116,17 @@ export function initGenAttributeDagView(
1013
  const afterExclude = excludeNodeAggregatedEntries(step, aggregated, excludeIntervalContext);
1014
  const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage });
1015
 
1016
- const massSum = selected.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
1017
- for (const item of selected) {
 
 
 
 
 
 
 
 
 
1018
  const srcId = item.nodeId;
1019
  if (!graph.hasNode(srcId)) {
1020
  throw new Error(
@@ -1032,7 +1144,8 @@ export function initGenAttributeDagView(
1032
  );
1033
  }
1034
  const edgeAttrs = {
1035
- strength: item.score,
 
1036
  scoreShare: share,
1037
  ...(alignmentNote ? { alignmentNote } : {}),
1038
  };
@@ -1109,6 +1222,31 @@ export function initGenAttributeDagView(
1109
  const rowMidY = bn.y + bn.height / 2;
1110
  const ty = pad + innerH / 2 - k * rowMidY;
1111
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
  } else if (layoutMode === 'text-flow') {
1113
  /** 与原实现一致:`rootG` 整包 bbox + 宽高双约束顶对齐 */
1114
  const b = rootG.node()!.getBBox();
@@ -1291,6 +1429,7 @@ export function initGenAttributeDagView(
1291
  setDagCompactness,
1292
  setEdgeTopPCoverage,
1293
  setHideExcludedTokens,
 
1294
  detach,
1295
  };
1296
  }
 
10
  phase2RankAndSparsify,
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
13
+ import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
14
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
15
  import {
16
  alignAndAggregateByNode,
 
20
  } from './genAttributeDagIntervalResolve';
21
  import type { TokenGenStep } from './tokenGenAttributionRunner';
22
  import { createGenAttributeDagTextMeasure } from './genAttributeDagTextMeasure';
23
+ import { formatTopkTooltipProbabilityPercent } from '../utils/topkChartUtils';
24
  import {
25
  CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT,
26
  dagResultsSurfaceFullscreenExpanded,
 
36
  paintLinearArcLayout,
37
  } from './genAttributeDagViewLinearArcMode';
38
  import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
39
+ import { paintSpiralLayout } from './genAttributeDagViewSpiralMode';
40
  import { tr } from '../lang/i18n-lite';
41
 
42
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
43
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
44
 
45
+ /** 节点布局模式:`text-flow` 按文字排版层几何;`linear-arc` 按节点插入序线性排布 + 弧线连边;`spiral` 螺旋排布。 */
46
+ export type DagLayoutMode = 'text-flow' | 'linear-arc' | 'spiral';
47
 
48
  export const DAG_COMPACTNESS_DEFAULT = 0.5;
49
  /** 下限取小正数以满足 {@link readDisplayScaleFromCss}「必须为正」且不出现零宽度边线。 */
 
55
  return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n));
56
  }
57
 
58
+ /**
59
+ * 零信心概率基准 p₀:surprisal log₂(1/p₀) 视作单 token 的绝对信息量参照(此处 20 bit)。
60
+ * p = p₀ 时 {@link computeMutualInformationRatio} 为 0。
61
+ */
62
+ const ZERO_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -20;
63
+
64
+ function clamp01(n: number): number {
65
+ return Math.min(1, Math.max(0, n));
66
+ }
67
+
68
+ /**
69
+ * 互信息率 α:在参照熵 log₂(1/p₀) 下,将「前文与目标 token 的可对齐程度」
70
+ * (log₂(1/p₀) − log₂(1/p)) / log₂(1/p₀) = log₂(p/p₀) / log₂(1/p₀) clamp 到 [0,1]。
71
+ * 低 surprisal → 高 α;仅用于本步入边透明度,不参与边筛选。缺省 `target_prob` 时返回 1(兼容旧缓存)。
72
+ */
73
+ function computeMutualInformationRatio(targetProb: number | undefined): number {
74
+ if (targetProb === undefined) return 1;
75
+ if (!Number.isFinite(targetProb) || targetProb <= 0) return 0;
76
+
77
+ return clamp01(
78
+ Math.log2(targetProb / ZERO_CONFIDENCE_PROBABILITY_BASELINE) /
79
+ Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE)
80
+ );
81
+ }
82
+
83
+ /**
84
+ * 节点/边原生 `<title>` 中互信息率 α 的展示:α∈[0,1] 转为百分号字符串,
85
+ * 与 analysis 主视图 Tooltip 中 Top-K 概率列 {@link formatTopkTooltipProbabilityPercent} 同形。
86
+ */
87
+ function formatMutualInformationRatioForTooltip(miRatio: number): string {
88
+ if (!Number.isFinite(miRatio)) return String(miRatio);
89
+ return formatTopkTooltipProbabilityPercent(miRatio);
90
+ }
91
+
92
  export {
93
  clampLinearArcAdjacentGap,
94
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
 
126
  type DagLink = {
127
  source: string;
128
  target: string;
129
+ /**
130
+ * 候选池max 归一后归因分,区间约 [0, 1]作为 `stroke-opacity` 的基项(再乘 {@link mutualInformationRatio})。
131
+ * 池内稀疏化与建边前过滤均使用 {@link DAG_EDGE_MIN_DISPLAY_OPACITY}(见 genAttributeDagEdgeDisplay);条件为 {@link dagLinkStrokeOpacity} 不低于该阈值。
132
+ */
133
+ normalizedScore?: number;
134
+ /** 互信息率:仅作为本步入边的视觉透明度系数,不参与归因筛选。 */
135
+ mutualInformationRatio?: number;
136
+ /** 本步内:该边池内 L1 份额在「仅可见边」({@link DAG_EDGE_MIN_DISPLAY_OPACITY} 过滤后)上的占比;用于原生 title「Fan in share」 */
137
  scoreShare?: number;
138
  /** 与 `console.warn('[genAttributeDagView.align] …')` 正文一致(可多条,换行拼接) */
139
  alignmentNote?: string;
 
141
  titleText: string;
142
  };
143
 
144
+ /** 与 {@link refreshNodeLinkHighlight} 中边的 `stroke-opacity` 一致:`normalizedScore × mutualInformationRatio`。 */
145
+ function dagLinkStrokeOpacity(d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio'>): number {
146
+ return (d.normalizedScore ?? 1) * (d.mutualInformationRatio ?? 1);
147
+ }
148
+
149
  function dagLinkEndpointKey(source: string, target: string): string {
150
  return `${source}->${target}`;
151
  }
 
204
  };
205
  }
206
 
207
+ /** text-flow:在「抵消 display-scale」基准上初始 zoom 倍率(d3 的 k) */
208
+ const DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW = 2;
209
+ /** linear-arc:同上 */
210
+ const DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC = 4;
211
+ /** spiral:同上 */
212
+ const DAG_INITIAL_ZOOM_BOOST_SPIRAL = 2;
213
+
214
+ function dagInitialZoomBoost(mode: DagLayoutMode): number {
215
+ switch (mode) {
216
+ case 'text-flow':
217
+ return DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW;
218
+ case 'linear-arc':
219
+ return DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC;
220
+ case 'spiral':
221
+ return DAG_INITIAL_ZOOM_BOOST_SPIRAL;
222
+ default: {
223
+ const _: never = mode;
224
+ throw new Error(`genAttributeDagView: unknown DagLayoutMode (${String(_)})`);
225
+ }
226
+ }
227
+ }
228
 
229
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-compactness` 一致(display-scale/link 线粗等同源派生) */
230
  const CSS_VAR_DAG_COMPACTNESS = '--gen-attr-dag-compactness';
 
246
  const DAG_NODE_HIDDEN_OPACITY = 0.1;
247
 
248
  /** 暂时关闭节点上的原生 `<title>` 悬浮提示;恢复时改为 `false`(边不受影响) */
249
+ const DISABLE_DAG_NODE_TOOLTIPS = false;
250
 
251
  /**
252
  * 边端在矩形边界外侧的留白,相对测量层「1em」的比例(无单位);与箭头/描边衔接用。
 
327
  */
328
  reset(preserveUserViewport?: boolean): void;
329
  /**
330
+ * zoom identity 后按内容适配视口;空图走默认缩放;`k` 上限 `k₀`(随当前布局模式的初始 zoom 倍率变化)
331
  * - `text-flow`:`rootG.getBBox()`(含边)等比落入内框。
332
  * - `linear-arc`:仅按 `gen-attr-dag-nodes` 行宽定比,token 行相对内框竖直居中(弧不参与)。
333
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
 
365
  * - `false`(默认):保留为低透明度({@link DAG_NODE_HIDDEN_OPACITY})占位。
366
  */
367
  setHideExcludedTokens(hide: boolean): void;
368
+ /** prompt 层节点是否已注入(即 {@link setPromptTokenSpans} 至少成功添加过一个节点) */
369
+ hasPromptSpans(): boolean;
370
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
371
  detach(): void;
372
  };
 
391
  return `[${a}, ${b})`;
392
  }
393
 
394
+ function buildNodeNativeTitleText(
395
+ d: Pick<DagNode, 'displayLabel' | 'id' | 'step'> & { targetProb?: number },
396
+ ): string {
397
+ const lines = [
398
+ d.displayLabel,
399
+ `Offset: ${formatNodeOffsetRange(d.id)}`,
400
+ `Step: ${d.step}`,
401
+ ];
402
+ const { targetProb } = d;
403
+ if (targetProb !== undefined && Number.isFinite(targetProb)) {
404
+ lines.push(`Prob: ${formatTopkTooltipProbabilityPercent(targetProb)}`);
405
+ lines.push(`MI ratio: ${formatMutualInformationRatioForTooltip(computeMutualInformationRatio(targetProb))}`);
406
+ }
407
+ return lines.join('\n');
408
  }
409
 
410
  /** 建边时调用:端点已带 {@link DagNodeAttrs.displayLabel} */
411
  function buildLinkTitleText(
412
+ d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio' | 'scoreShare' | 'alignmentNote'>,
413
  src: DagNode,
414
  tgt: DagNode
415
  ): string {
416
+ const s = d.normalizedScore ?? 1;
417
+ const normStr = Number.isFinite(s) ? s.toFixed(3) : String(s);
418
+ const opacity = dagLinkStrokeOpacity(d);
419
+ const opacityStr = Number.isFinite(opacity) ? opacity.toFixed(3) : String(opacity);
420
+
421
+ const metrics = [
422
+ `Attribution score: ${normStr}`,
423
+ `Target MI ratio: ${formatMutualInformationRatioForTooltip(d.mutualInformationRatio ?? 1)}`,
424
+ `Link strength: ${opacityStr}`,
425
+ ];
426
  const share = d.scoreShare;
427
  if (typeof share === 'number' && Number.isFinite(share) && share > 0) {
428
  metrics.push(`Fan in share: ${(share * 100).toFixed(1)}%`);
 
602
  setDagCompactness: noop,
603
  setEdgeTopPCoverage: noop,
604
  setHideExcludedTokens: noop,
605
+ hasPromptSpans: () => false,
606
  detach: noop,
607
  };
608
  }
 
617
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
618
  const stackEl = stack.node() as HTMLElement;
619
 
620
+ /** 非 text-flow 节点不可拖;用该类覆盖选中态的 grab 光标(linear-arc / spiral 等)。 */
621
  function syncStackLayoutDragUi(): void {
622
+ stackEl.classList.toggle('gen-attr-dag-no-node-drag-layout', layoutMode !== 'text-flow');
623
  }
624
  syncStackLayoutDragUi();
625
 
 
686
 
687
  /**
688
  * 基准缩放为 `1 / --gen-attr-dag-display-scale`:节点几何与 SVG 文字已按 display-scale 相对测量层缩放后,
689
+ * 再用其倒数做 zoom,使屏上接近未单独缩小时的阅读比例;实际初始 k 还会乘以 {@link dagInitialZoomBoost}(按布局模式)
690
  */
691
  function initialDagZoomK(): number {
692
  return 1 / displayScale;
693
  }
694
 
695
  function defaultDagZoomK(): number {
696
+ return initialDagZoomK() * dagInitialZoomBoost(layoutMode);
697
  }
698
 
699
  const zoomBehavior = d3
 
747
  */
748
  let userDraggedNodes = false;
749
 
 
 
 
750
  let linkSel = rootG
751
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
752
  .data<DagLink>([], dagLinkDataKey);
 
774
  });
775
  return;
776
  }
777
+ if (layoutMode === 'spiral') {
778
+ const layoutNodes = hideExcludedTokens
779
+ ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
780
+ : nodes;
781
+ paintSpiralLayout({
782
+ linkSel,
783
+ nodeSel,
784
+ nodes: layoutNodes,
785
+ linkEndInsetPx,
786
+ getLinkNodes: (d) => ({
787
+ src: endpointNode(d.source, graph),
788
+ tgt: endpointNode(d.target, graph),
789
+ }),
790
+ });
791
+ return;
792
+ }
793
  paintTextFlowLayout({
794
  linkSel,
795
  nodeSel,
 
848
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
849
  return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY;
850
  }
851
+ const hasGenTokens = nodes.some((n) => n.step >= 0);
852
+ const isPromptLeaf = hasGenTokens && d.step === -1 && graph.outDegree(d.id) === 0;
853
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
854
  return 1;
855
  });
856
+ // 每条边独立 marker:线与箭头 path 同步 stroke / stroke-opacity
857
+ // normalizedScore 决定边内相对强弱(与 opacity 基项一致);互信息率只作为整步入边的视觉折扣。
858
  linkSel.each(function(d) {
859
+ const op = dagLinkStrokeOpacity(d);
 
860
  const stroke =
861
  dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`;
862
  const g = d3.select(this);
 
939
  return g;
940
  });
941
  // 不在此处全量重置 marker `stroke-opacity`:紧接着的 {@link refreshNodeLinkHighlight} 会按边
942
+ // 逐条写 `dagLinkStrokeOpacity`(与 `<title>` 中 Strength 同源),任何前值都会被覆盖,全量重置纯冗余。
943
 
944
  nodeSel = nodeG
945
  .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node')
 
1096
  displayLabel,
1097
  id: targetId,
1098
  step: stepProcessed,
1099
+ targetProb: response.target_prob,
1100
  }),
1101
  };
1102
  graph.addNode(targetId, targetNode);
 
1116
  const afterExclude = excludeNodeAggregatedEntries(step, aggregated, excludeIntervalContext);
1117
  const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage });
1118
 
1119
+ const mutualInformationRatio = computeMutualInformationRatio(response.target_prob);
1120
+ // 仅保留可绘制的边;「Fan in share」的分母为下列可见边的池内 L1 份额之和(非完整 sparse 池)。
1121
+ const selectedForDisplay = selected.filter(
1122
+ (item) =>
1123
+ dagLinkStrokeOpacity({
1124
+ normalizedScore: item.score,
1125
+ mutualInformationRatio,
1126
+ }) >= DAG_EDGE_MIN_DISPLAY_OPACITY
1127
+ );
1128
+ const massSum = selectedForDisplay.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
1129
+ for (const item of selectedForDisplay) {
1130
  const srcId = item.nodeId;
1131
  if (!graph.hasNode(srcId)) {
1132
  throw new Error(
 
1144
  );
1145
  }
1146
  const edgeAttrs = {
1147
+ normalizedScore: item.score,
1148
+ mutualInformationRatio,
1149
  scoreShare: share,
1150
  ...(alignmentNote ? { alignmentNote } : {}),
1151
  };
 
1222
  const rowMidY = bn.y + bn.height / 2;
1223
  const ty = pad + innerH / 2 - k * rowMidY;
1224
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1225
+ } else if (layoutMode === 'spiral') {
1226
+ /**
1227
+ * 螺旋:等比缩放 + 视口中心对齐曲线原点 (0,0)({@link paintSpiralLayout} 坐标),
1228
+ * 避免按 bbox 中心 fit 时随步进增长 centroid 漂移导致播放抖动。
1229
+ */
1230
+ const b = rootG.node()!.getBBox();
1231
+ const xmin = b.x;
1232
+ const xmax = b.x + b.width;
1233
+ const ymin = b.y;
1234
+ const ymax = b.y + b.height;
1235
+ const halfW = innerW / 2;
1236
+ const halfH = innerH / 2;
1237
+ let kFromOrigin = Infinity;
1238
+ if (xmax > 0) kFromOrigin = Math.min(kFromOrigin, halfW / xmax);
1239
+ if (xmin < 0) kFromOrigin = Math.min(kFromOrigin, halfW / (-xmin));
1240
+ if (ymax > 0) kFromOrigin = Math.min(kFromOrigin, halfH / ymax);
1241
+ if (ymin < 0) kFromOrigin = Math.min(kFromOrigin, halfH / (-ymin));
1242
+ const bw = Math.max(b.width, 1e-6);
1243
+ const bh = Math.max(b.height, 1e-6);
1244
+ const kFromSides = Math.min(innerW / bw, innerH / bh);
1245
+ const kRaw = Number.isFinite(kFromOrigin) && kFromOrigin > 0 ? kFromOrigin : kFromSides;
1246
+ const k = Math.min(kRaw, k0);
1247
+ const tx = pad + halfW;
1248
+ const ty = pad + halfH;
1249
+ svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1250
  } else if (layoutMode === 'text-flow') {
1251
  /** 与原实现一致:`rootG` 整包 bbox + 宽高双约束顶对齐 */
1252
  const b = rootG.node()!.getBBox();
 
1429
  setDagCompactness,
1430
  setEdgeTopPCoverage,
1431
  setHideExcludedTokens,
1432
+ hasPromptSpans: () => nodes.some((n) => n.step === -1),
1433
  detach,
1434
  };
1435
  }
client/src/ts/attribution/genAttributeDagViewSpiralMode.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as d3 from 'd3';
2
+ import { linkSegmentThroughNodeRects } from './genAttributeDagLinkSegment';
3
+
4
+ // ── 可配置参数(代码变量,后续可暴露为 UI 控件)────────────────────────────
5
+ /** 第一个 token 的起始半径(px):0 = 正中心,> 0 = 距中心该距离处。 */
6
+ const SPIRAL_R0 = 80;
7
+ /** 相邻两圈之间的径向间距(px)。 */
8
+ const SPIRAL_SPACING = 60;
9
+ /** 每个 token 沿螺旋弧长占据的固定步长(px)。 */
10
+ const SPIRAL_ARC_STEP = 40;
11
+ /** 螺旋旋转相位(弧度):控制螺旋臂展开方向。0 = 向右,-Math.PI/2 = 向上。 */
12
+ const SPIRAL_PHASE = Math.PI * 0.6;
13
+ // ────────────────────────────────────────────────────────────────────────────
14
+
15
+ type SpiralNodeLike = { nodeW: number; nodeH: number };
16
+
17
+ /**
18
+ * 阿基米德螺旋:r(θ) = b·θ,b = spacing / (2π)。
19
+ *
20
+ * theta 从 r0/b 起步,使第一个 token 位于半径 r0 处。
21
+ * 相位 phase 叠加到 cos/sin 的角度,只旋转螺旋臂方向,不影响 r 的增长。
22
+ * 弧长步进:Δθ ≈ arcStep / sqrt(r² + b²)。
23
+ */
24
+ function computeSpiralPositions(
25
+ count: number,
26
+ r0: number,
27
+ spacing: number,
28
+ arcStep: number,
29
+ phase: number,
30
+ ): { cx: number; cy: number }[] {
31
+ const b = spacing / (2 * Math.PI);
32
+ let theta = r0 / b;
33
+ const positions: { cx: number; cy: number }[] = [];
34
+
35
+ for (let i = 0; i < count; i++) {
36
+ const r = b * theta;
37
+ positions.push({
38
+ cx: r * Math.cos(theta + phase),
39
+ cy: r * Math.sin(theta + phase),
40
+ });
41
+ theta += arcStep / Math.sqrt(r * r + b * b);
42
+ }
43
+ return positions;
44
+ }
45
+
46
+ /** spiral 模式:token 中心依次落在阿基米德螺旋上,节点保持水平矩形。 */
47
+ export function paintSpiralLayout<
48
+ LinkDatum,
49
+ NodeDatum extends SpiralNodeLike,
50
+ >(params: {
51
+ linkSel: d3.Selection<SVGGElement, LinkDatum, SVGGElement, unknown>;
52
+ nodeSel: d3.Selection<SVGGElement, NodeDatum, SVGGElement, unknown>;
53
+ nodes: NodeDatum[];
54
+ linkEndInsetPx: number;
55
+ getLinkNodes: (link: LinkDatum) => { src: NodeDatum; tgt: NodeDatum };
56
+ }): void {
57
+ const { linkSel, nodeSel, nodes, linkEndInsetPx, getLinkNodes } = params;
58
+
59
+ const rawPos = computeSpiralPositions(nodes.length, SPIRAL_R0, SPIRAL_SPACING, SPIRAL_ARC_STEP, SPIRAL_PHASE);
60
+
61
+ const positionByNode = new Map<NodeDatum, { cx: number; cy: number }>();
62
+ for (let i = 0; i < nodes.length; i++) {
63
+ positionByNode.set(nodes[i]!, rawPos[i]!);
64
+ }
65
+
66
+ // 节点:中心落在螺旋点,矩形保持水平
67
+ nodeSel.attr('transform', (d) => {
68
+ const pos = positionByNode.get(d);
69
+ if (pos === undefined) return null;
70
+ return `translate(${pos.cx - d.nodeW / 2},${pos.cy - d.nodeH / 2})`;
71
+ });
72
+
73
+ // 边:与 text-flow 相同,从矩形边界起止并回缩
74
+ linkSel.each(function(d) {
75
+ const { src, tgt } = getLinkNodes(d);
76
+ const pa = positionByNode.get(src);
77
+ const pb = positionByNode.get(tgt);
78
+ if (pa === undefined || pb === undefined) return;
79
+ const srcRect = {
80
+ x: pa.cx - src.nodeW / 2,
81
+ y: pa.cy - src.nodeH / 2,
82
+ nodeW: src.nodeW,
83
+ nodeH: src.nodeH,
84
+ };
85
+ const tgtRect = {
86
+ x: pb.cx - tgt.nodeW / 2,
87
+ y: pb.cy - tgt.nodeH / 2,
88
+ nodeW: tgt.nodeW,
89
+ nodeH: tgt.nodeH,
90
+ };
91
+ const seg = linkSegmentThroughNodeRects(srcRect, tgtRect, linkEndInsetPx);
92
+ d3.select(this)
93
+ .selectAll('path.gen-attr-dag-link-visible')
94
+ .attr('d', `M ${seg.x1} ${seg.y1} L ${seg.x2} ${seg.y2}`);
95
+ });
96
+ }
client/src/ts/attribution/genAttributeDagViewTextFlowMode.ts CHANGED
@@ -1,4 +1,5 @@
1
  import * as d3 from 'd3';
 
2
 
3
  type TextFlowNodeLike = {
4
  x: number;
@@ -7,50 +8,6 @@ type TextFlowNodeLike = {
7
  nodeH: number;
8
  };
9
 
10
- function nodeCenter(n: TextFlowNodeLike): { cx: number; cy: number } {
11
- return { cx: n.x + n.nodeW / 2, cy: n.y + n.nodeH / 2 };
12
- }
13
-
14
- /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离。 */
15
- function distCenterToRectEdgeAlongRay(hw: number, hh: number, ux: number, uy: number): number {
16
- const ax = Math.abs(ux);
17
- const ay = Math.abs(uy);
18
- let t = Infinity;
19
- if (ax > 1e-12) t = Math.min(t, hw / ax);
20
- if (ay > 1e-12) t = Math.min(t, hh / ay);
21
- return Number.isFinite(t) ? t : 0;
22
- }
23
-
24
- /** text-flow 模式边几何:从两节点矩形边界连线,必要时退化为中心直连。 */
25
- function linkSegmentThroughNodeRects(
26
- src: TextFlowNodeLike,
27
- tgt: TextFlowNodeLike,
28
- outsideInset: number
29
- ): { x1: number; y1: number; x2: number; y2: number } {
30
- const a = nodeCenter(src);
31
- const b = nodeCenter(tgt);
32
- const dx = b.cx - a.cx;
33
- const dy = b.cy - a.cy;
34
- const L = Math.hypot(dx, dy);
35
- if (L < 1e-9) return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
36
- const ux = dx / L;
37
- const uy = dy / L;
38
- const tA = distCenterToRectEdgeAlongRay(src.nodeW / 2, src.nodeH / 2, ux, uy);
39
- const tB = distCenterToRectEdgeAlongRay(tgt.nodeW / 2, tgt.nodeH / 2, ux, uy);
40
- const eps = 1e-6;
41
- let g = outsideInset;
42
- if (tA + tB + 2 * g >= L - eps) g = 0;
43
- if (tA + tB + 2 * g >= L - eps) {
44
- return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
45
- }
46
- return {
47
- x1: a.cx + (tA + g) * ux,
48
- y1: a.cy + (tA + g) * uy,
49
- x2: b.cx - (tB + g) * ux,
50
- y2: b.cy - (tB + g) * uy,
51
- };
52
- }
53
-
54
  /** text-flow 模式:节点使用测量层坐标,边按节点矩形几何连接。 */
55
  export function paintTextFlowLayout<LinkDatum, NodeDatum extends TextFlowNodeLike>(params: {
56
  linkSel: d3.Selection<SVGGElement, LinkDatum, SVGGElement, unknown>;
 
1
  import * as d3 from 'd3';
2
+ import { linkSegmentThroughNodeRects } from './genAttributeDagLinkSegment';
3
 
4
  type TextFlowNodeLike = {
5
  x: number;
 
8
  nodeH: number;
9
  };
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  /** text-flow 模式:节点使用测量层坐标,边按节点矩形几何连接。 */
12
  export function paintTextFlowLayout<LinkDatum, NodeDatum extends TextFlowNodeLike>(params: {
13
  linkSel: d3.Selection<SVGGElement, LinkDatum, SVGGElement, unknown>;
client/src/ts/attribution/predictionAttributeClient.ts CHANGED
@@ -1,8 +1,9 @@
1
  /**
2
- * /api/prediction-attribute:统一请求JSON 解析与归因结果缓存写入
3
- * 命中缓存与 MRU 规则见 {@link ./attributionResultCache}。
4
  */
5
  import type { AttributionApiResponse, PredictionAttributeModelVariant } from './attributionResultCache';
 
6
  import {
7
  entryKey,
8
  removeCachedEntryByContentKey,
@@ -16,12 +17,16 @@ export async function fetchPredictionAttribute(
16
  apiBaseForRequests: string,
17
  context: string,
18
  targetPrediction: string | null,
19
- model: PredictionAttributeModelVariant
 
20
  ): Promise<AttributionApiResponse> {
21
  const bodyObj: Record<string, unknown> = { context, model };
22
  if (targetPrediction !== null) {
23
  bodyObj.target_prediction = targetPrediction;
24
  }
 
 
 
25
  const res = await fetch(`${apiBaseForRequests}/api/prediction-attribute`, {
26
  method: 'POST',
27
  headers: { 'Content-Type': 'application/json' },
@@ -76,3 +81,31 @@ export async function loadPredictionAttributeWithCache(
76
  await save({ context, targetPrediction }, json, 'complete');
77
  return json;
78
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
+ * /api/prediction-attribute 与 /api/tokenize:统一请求JSON 解析。
3
+ * 归因缓存规则见 {@link ./attributionResultCache}。
4
  */
5
  import type { AttributionApiResponse, PredictionAttributeModelVariant } from './attributionResultCache';
6
+ import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
  import {
8
  entryKey,
9
  removeCachedEntryByContentKey,
 
17
  apiBaseForRequests: string,
18
  context: string,
19
  targetPrediction: string | null,
20
+ model: PredictionAttributeModelVariant,
21
+ targetTokenId?: number
22
  ): Promise<AttributionApiResponse> {
23
  const bodyObj: Record<string, unknown> = { context, model };
24
  if (targetPrediction !== null) {
25
  bodyObj.target_prediction = targetPrediction;
26
  }
27
+ if (typeof targetTokenId === 'number' && Number.isInteger(targetTokenId) && targetTokenId >= 0) {
28
+ bodyObj.target_token_id = targetTokenId;
29
+ }
30
  const res = await fetch(`${apiBaseForRequests}/api/prediction-attribute`, {
31
  method: 'POST',
32
  headers: { 'Content-Type': 'application/json' },
 
81
  await save({ context, targetPrediction }, json, 'complete');
82
  return json;
83
  }
84
+
85
+ /**
86
+ * POST /api/tokenize:快速分词,返回 prompt 各 token 的 offset + raw。
87
+ * 不占推理锁,响应极快,用于在 DAG 模式流式生成时提前展示 prompt 节点。
88
+ */
89
+ export async function fetchTokenize(
90
+ apiBase: string,
91
+ context: string,
92
+ model: PredictionAttributeModelVariant,
93
+ ): Promise<PromptTokenSpan[]> {
94
+ const res = await fetch(`${apiBase}/api/tokenize`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ context, model }),
98
+ });
99
+ const text = await res.text();
100
+ let json: { success: boolean; spans?: PromptTokenSpan[]; message?: string };
101
+ try {
102
+ json = JSON.parse(text) as typeof json;
103
+ } catch {
104
+ const snippet = text.slice(0, 160) + (text.length > 160 ? '…' : '');
105
+ throw new Error(`/api/tokenize response is not JSON (HTTP ${res.status}): ${snippet}`);
106
+ }
107
+ if (!res.ok || !json.success) {
108
+ throw new Error(json.message ?? `HTTP ${res.status}`);
109
+ }
110
+ return json.spans ?? [];
111
+ }
client/src/ts/attribution/tokenGenAttributionRunner.ts CHANGED
@@ -1,10 +1,21 @@
1
  /**
2
- * 逐 token 生成归因:基于 /api/prediction-attribute (top-1 模式) 的贪心解码循环
3
- * 每次 API 调用 = 一次前向 pass(贪心解码一个 token)+ 对该 token 的完整归因。
4
  */
5
  import type { AttributionApiResponse, PredictionAttributeModelVariant } from './attributionResultCache';
 
6
  import type { CompletionFinishReason } from '../utils/generationEndReasonLabel';
7
- import { fetchPredictionAttribute } from './predictionAttributeClient';
 
 
 
 
 
 
 
 
 
 
8
 
9
  export type TokenGenStep = {
10
  /** 本步归因所用的 context(不含新 token) */
@@ -24,6 +35,16 @@ export type TokenGenAttributionOptions = {
24
  initialContext: string;
25
  apiPrefix: string;
26
  model: PredictionAttributeModelVariant;
 
 
 
 
 
 
 
 
 
 
27
  /** 最大生成 token 数,默认 200 */
28
  maxTokens?: number;
29
  /** 每生成一个 token 后的回调;`stepIndex` 从 0 起,与 {@link TokenGenAttributionHandle.getAllSteps} 下标一致 */
@@ -41,13 +62,87 @@ export type TokenGenAttributionHandle = {
41
  };
42
 
43
  export function startTokenGenAttribution(opts: TokenGenAttributionOptions): TokenGenAttributionHandle {
44
- const { initialContext, apiPrefix, model, maxTokens = 200 } = opts;
 
 
45
  const promptRegionEnd = initialContext.length;
46
  let aborted = false;
47
  let generatedText = '';
 
 
 
48
  const steps: TokenGenStep[] = [];
49
 
50
  const loop = async (): Promise<void> => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  while (true) {
52
  if (aborted) {
53
  opts.onComplete('abort');
@@ -57,12 +152,19 @@ export function startTokenGenAttribution(opts: TokenGenAttributionOptions): Toke
57
  opts.onComplete('length');
58
  return;
59
  }
 
 
 
 
 
60
 
61
  const context = initialContext + generatedText;
 
 
 
62
  let response: AttributionApiResponse;
63
  try {
64
- // target_prediction null 服务端 top-1 贪心解码
65
- response = await fetchPredictionAttribute(apiPrefix, context, null, model);
66
  } catch (err) {
67
  const error = err instanceof Error ? err : new Error(String(err));
68
  opts.onError(error);
@@ -75,9 +177,30 @@ export function startTokenGenAttribution(opts: TokenGenAttributionOptions): Toke
75
  return;
76
  }
77
 
78
- const token = response.target_token ?? '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  generatedText += token;
80
 
 
 
 
 
 
81
  const step: TokenGenStep = {
82
  context,
83
  promptRegionEnd,
 
1
  /**
2
+ * 逐 token 生成归因:基于 /api/prediction-attribute。
3
+ * 默认 `target_prediction` 为空 服务端 top-1 贪心;传入 {@link TokenGenAttributionOptions.teacherForcingContinuation} 时按用户续写逐步强制首 token 归因。
4
  */
5
  import type { AttributionApiResponse, PredictionAttributeModelVariant } from './attributionResultCache';
6
+ import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
  import type { CompletionFinishReason } from '../utils/generationEndReasonLabel';
8
+ import { fetchPredictionAttribute, fetchTokenize } from './predictionAttributeClient';
9
+
10
+ function splitCodePointPrefix(text: string, prefixLength: number): { prefix: string; rest: string } | null {
11
+ if (prefixLength < 0) return null;
12
+ const chars = Array.from(text);
13
+ if (prefixLength > chars.length) return null;
14
+ return {
15
+ prefix: chars.slice(0, prefixLength).join(''),
16
+ rest: chars.slice(prefixLength).join(''),
17
+ };
18
+ }
19
 
20
  export type TokenGenStep = {
21
  /** 本步归因所用的 context(不含新 token) */
 
35
  initialContext: string;
36
  apiPrefix: string;
37
  model: PredictionAttributeModelVariant;
38
+ /**
39
+ * 非空则启用 teacher forcing:启动时仅调用一次 `/api/tokenize` 预取 token_id,
40
+ * 后续每步通过 `target_token_id` 指定归因目标,并按 spans 的码点覆盖推进。
41
+ */
42
+ teacherForcingContinuation?: string;
43
+ /**
44
+ * teacher forcing token 用尽后是否停止。
45
+ * `true`:停止;`false`(默认):切换为 top-1 继续生成,直到 maxTokens 或 EOS。
46
+ */
47
+ stopAfterTeacherForcing?: boolean;
48
  /** 最大生成 token 数,默认 200 */
49
  maxTokens?: number;
50
  /** 每生成一个 token 后的回调;`stepIndex` 从 0 起,与 {@link TokenGenAttributionHandle.getAllSteps} 下标一致 */
 
62
  };
63
 
64
  export function startTokenGenAttribution(opts: TokenGenAttributionOptions): TokenGenAttributionHandle {
65
+ const { initialContext, apiPrefix, model, maxTokens = 200, stopAfterTeacherForcing = false } = opts;
66
+ const tfOpt = opts.teacherForcingContinuation;
67
+ const forcingEnabled = typeof tfOpt === 'string' && tfOpt.length > 0;
68
  const promptRegionEnd = initialContext.length;
69
  let aborted = false;
70
  let generatedText = '';
71
+ let remainingForcing = tfOpt ?? '';
72
+ let forcingPieces: Array<{ token: string; tokenId: number }> = [];
73
+ let forcingPieceIndex = 0;
74
  const steps: TokenGenStep[] = [];
75
 
76
  const loop = async (): Promise<void> => {
77
+ if (forcingEnabled) {
78
+ let spans;
79
+ try {
80
+ spans = await fetchTokenize(apiPrefix, tfOpt, model);
81
+ } catch (err) {
82
+ const error = err instanceof Error ? err : new Error(String(err));
83
+ opts.onError(error);
84
+ opts.onComplete('error');
85
+ return;
86
+ }
87
+ if (!spans.length) {
88
+ opts.onError(new Error('Teacher forcing tokenize returned empty spans.'));
89
+ opts.onComplete('error');
90
+ return;
91
+ }
92
+ const chars = Array.from(tfOpt);
93
+ let cursor = 0;
94
+ const pieces: Array<{ token: string; tokenId: number }> = [];
95
+ for (const span of spans) {
96
+ const [start, end] = span.offset;
97
+ const tokenId = (span as PromptTokenSpan).token_id;
98
+ if (start < 0 || end <= start || end > chars.length) {
99
+ opts.onError(
100
+ new Error(`Teacher forcing tokenize returned invalid span [${start}, ${end}) for continuation.`)
101
+ );
102
+ opts.onComplete('error');
103
+ return;
104
+ }
105
+ if (start > cursor) {
106
+ opts.onError(
107
+ new Error(
108
+ `Teacher forcing tokenize produced gap: span starts at ${start} but consumed cursor is ${cursor}.`
109
+ )
110
+ );
111
+ opts.onComplete('error');
112
+ return;
113
+ }
114
+ if (end <= cursor) {
115
+ continue;
116
+ }
117
+ if (typeof tokenId !== 'number' || !Number.isInteger(tokenId) || tokenId < 0) {
118
+ opts.onError(
119
+ new Error(
120
+ `Teacher forcing tokenize span is missing token_id at offset [${start}, ${end}).`
121
+ )
122
+ );
123
+ opts.onComplete('error');
124
+ return;
125
+ }
126
+ pieces.push({ token: chars.slice(cursor, end).join(''), tokenId });
127
+ cursor = end;
128
+ }
129
+ if (cursor !== chars.length) {
130
+ opts.onError(
131
+ new Error(
132
+ `Teacher forcing tokenize did not fully cover continuation: consumed ${cursor}/${chars.length} code points.`
133
+ )
134
+ );
135
+ opts.onComplete('error');
136
+ return;
137
+ }
138
+ if (!pieces.length) {
139
+ opts.onError(new Error('Teacher forcing tokenize produced no consumable pieces.'));
140
+ opts.onComplete('error');
141
+ return;
142
+ }
143
+ forcingPieces = pieces;
144
+ }
145
+
146
  while (true) {
147
  if (aborted) {
148
  opts.onComplete('abort');
 
152
  opts.onComplete('length');
153
  return;
154
  }
155
+ const forcingExhausted = forcingEnabled && forcingPieceIndex >= forcingPieces.length;
156
+ if (forcingExhausted && stopAfterTeacherForcing) {
157
+ opts.onComplete('stop');
158
+ return;
159
+ }
160
 
161
  const context = initialContext + generatedText;
162
+ const targetTokenId =
163
+ forcingEnabled && !forcingExhausted ? forcingPieces[forcingPieceIndex]!.tokenId : undefined;
164
+
165
  let response: AttributionApiResponse;
166
  try {
167
+ response = await fetchPredictionAttribute(apiPrefix, context, null, model, targetTokenId);
 
168
  } catch (err) {
169
  const error = err instanceof Error ? err : new Error(String(err));
170
  opts.onError(error);
 
177
  return;
178
  }
179
 
180
+ let token = response.target_token ?? '';
181
+
182
+ if (forcingEnabled && !forcingExhausted) {
183
+ token = forcingPieces[forcingPieceIndex]!.token;
184
+ const sliced = splitCodePointPrefix(remainingForcing, Array.from(token).length);
185
+ if (!sliced) {
186
+ opts.onError(
187
+ new Error(
188
+ `Teacher forcing piece consume failed at step=${forcingPieceIndex}: token="${token}", remaining="${remainingForcing}"`
189
+ )
190
+ );
191
+ opts.onComplete('error');
192
+ return;
193
+ }
194
+ remainingForcing = sliced.rest;
195
+ forcingPieceIndex++;
196
+ }
197
  generatedText += token;
198
 
199
+ if (aborted) {
200
+ opts.onComplete('abort');
201
+ return;
202
+ }
203
+
204
  const step: TokenGenStep = {
205
  context,
206
  promptRegionEnd,
client/src/ts/chat/buildCompletionDisplayResult.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
  } from '../utils/dataValidation';
10
 
11
  /** 与手动 curl 调试一致的默认 model 字段 */
12
- export const CHAT_DEFAULT_COMPLETION_MODEL = 'any-id';
13
 
14
  function normalizeServerTokens(raw: TokenWithOffset[]): FrontendToken[] {
15
  return raw.map((t) => ({
 
9
  } from '../utils/dataValidation';
10
 
11
  /** 与手动 curl 调试一致的默认 model 字段 */
12
+ export const CHAT_DEFAULT_COMPLETION_MODEL = 'instruct';
13
 
14
  function normalizeServerTokens(raw: TokenWithOffset[]): FrontendToken[] {
15
  return raw.map((t) => ({
client/src/ts/gen_attribute.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
  clampDagEdgeTopPCoverage,
22
  DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
23
  extractPromptTokenSpans,
 
24
  } from './attribution/genAttributeDagPreprocess';
25
  import {
26
  initGenAttributeDagView,
@@ -36,6 +37,7 @@ import {
36
  type TokenGenAttributionHandle,
37
  type TokenGenStep,
38
  } from './attribution/tokenGenAttributionRunner';
 
39
  import { completionFinishReasonLabel, type CompletionFinishReason } from './utils/generationEndReasonLabel';
40
  import {
41
  buildCachedContentUrlParam,
@@ -44,6 +46,7 @@ import {
44
  removeCachedEntryByContentKey,
45
  save,
46
  touchCachedEntryByContentKey,
 
47
  } from './storage/genAttributeRunCache';
48
  import { bindExcludeGeneratedPatternsUi, bindExcludePromptPatternsUi } from './attribution/excludePromptPatternsUi';
49
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi';
@@ -63,10 +66,11 @@ import {
63
  } from './demos/genAttributeBundledDemos';
64
  import { extractErrorMessage } from './utils/errorUtils';
65
  import { exportJsonFile } from './storage/localFileIO';
66
- import type { GenAttrCachedRun } from './storage/genAttributeRunCache';
67
  import {
68
  GEN_ATTR_RAW_INPUT_HISTORY_KEY,
69
  GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY,
 
70
  GEN_ATTR_USER_INPUT_HISTORY_KEY,
71
  initQueryHistoryDropdown,
72
  saveHistory,
@@ -76,7 +80,6 @@ import {
76
  writeSkipChatTemplateToStorage,
77
  } from './utils/chatPromptTemplateMode';
78
  import { postCompletionsPrompt, postCompletionsStop } from './api/completionsClient';
79
- import { CHAT_DEFAULT_COMPLETION_MODEL } from './chat/buildCompletionDisplayResult';
80
  import { updateApiUsageDisplay, updateModel, validateMetricsElements } from './utils/textMetricsUpdater';
81
 
82
  d3.selectAll('.loadersmall').style('display', 'none');
@@ -239,7 +242,7 @@ function readStoredDagReplayPacingMode(): DagReplayPacingMode {
239
  function readStoredDagLayoutMode(): DagLayoutMode {
240
  try {
241
  const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
242
- if (v === 'text-flow' || v === 'linear-arc') return v;
243
  } catch {
244
  // ignore
245
  }
@@ -254,12 +257,6 @@ const apiBaseForRequests = apiPrefix === '' ? '' : String(apiPrefix);
254
  const adminManager = AdminManager.getInstance();
255
  api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
256
 
257
- const modelParam = URLHandler.parameters['model'];
258
- const completionModel =
259
- typeof modelParam === 'string' && modelParam.length > 0
260
- ? modelParam
261
- : CHAT_DEFAULT_COMPLETION_MODEL;
262
-
263
  // --- DOM ---
264
  const rawTextField = d3.select('#gen_attr_raw_text');
265
  const rawTextCountValue = d3.select('#gen_attr_raw_text_count_value');
@@ -279,6 +276,12 @@ const clearUserBtn = d3.select('#gen_attr_clear_user_btn');
279
  const pasteUserBtn = d3.select('#gen_attr_paste_user_btn');
280
  const userHistoryBtn = document.getElementById('gen_attr_user_history_btn');
281
 
 
 
 
 
 
 
282
  const rawInputPanel = document.getElementById('gen_attr_raw_input_panel');
283
  const chatInputPanel = document.getElementById('gen_attr_chat_input_panel');
284
  const skipChatTemplateInput = document.getElementById(
@@ -288,6 +291,13 @@ const genAttrUseSystemPromptInput = document.getElementById(
288
  'gen_attr_use_system_prompt'
289
  ) as HTMLInputElement | null;
290
  const genAttrSystemPromptPanel = document.getElementById('gen_attr_system_prompt_panel');
 
 
 
 
 
 
 
291
 
292
  const submitBtn = d3.select('#gen_attr_submit_btn');
293
  const loaderSmall = d3.select('.loadersmall');
@@ -340,19 +350,22 @@ function applyDagReplaySpeedUi(): void {
340
  }
341
 
342
  function currentDagLayoutMode(): DagLayoutMode {
343
- return dagLayoutModeSelect?.value === 'linear-arc' ? 'linear-arc' : 'text-flow';
 
 
344
  }
345
 
346
  function applyDagLayoutModeUi(): void {
347
- const textFlow = currentDagLayoutMode() === 'text-flow';
348
  if (dagCompactnessGroup) {
349
- dagCompactnessGroup.hidden = !textFlow;
 
350
  }
351
  if (dagMeasureWidthGroup) {
352
- dagMeasureWidthGroup.hidden = !textFlow;
353
  }
354
  if (dagLinearArcIntervalGroup) {
355
- dagLinearArcIntervalGroup.hidden = textFlow;
356
  }
357
  }
358
 
@@ -526,6 +539,37 @@ function getActivePromptValue(): string {
526
  return (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
527
  }
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  new TextInputController({
530
  textField: rawTextField,
531
  textCountValue: rawTextCountValue,
@@ -559,6 +603,17 @@ new TextInputController({
559
  showAlertDialog,
560
  });
561
 
 
 
 
 
 
 
 
 
 
 
 
562
  /** 与 DAG 节点 offset 同源的累积串,供跨 token 闭合后的排除区间(`excludeNodeAggregatedEntries`)。 */
563
  function excludeIntervalContextFromSteps(steps: TokenGenStep[]): string | undefined {
564
  if (steps.length === 0) return undefined;
@@ -574,7 +629,9 @@ function pushDagFromPreprocess(
574
  excludeIntervalContext?: string,
575
  ): void {
576
  if (stepIndex === 0) {
577
- dagHandle.setPromptTokenSpans(extractPromptTokenSpans(step), step.context);
 
 
578
  if (!dagHandle.isBatching() && fitOnFirstStep) {
579
  dagHandle.fitViewportToContent();
580
  }
@@ -585,18 +642,29 @@ function pushDagFromPreprocess(
585
  /** 下一步要 `pushDagFromPreprocess` 的步下标;与当前 DAG 前缀一致(暂停不重置) */
586
  let dagPlaybackNextIndex = 0;
587
 
588
- /** 将 handle 中已存步序按序重放进 DAG(调用方负责先 {@link dagHandle.reset} 等) */
589
- function replayRunnerStepsIntoDag(h: TokenGenAttributionHandle): void {
 
 
 
 
 
 
 
 
 
 
590
  if (h.tokenCount === 0) {
591
  dagPlaybackNextIndex = 0;
592
  return;
593
  }
594
- // 整段回放期间中间帧不可见:批处理内 `update` 只维护图数据,结束时统一刷一次 svg。
595
- // 避免 N 次 `syncGraphToSvg`(含 N 次对所有边的 join / paint / refresh)造成 O(N²) 累计开销。
596
  const steps = h.getAllSteps();
 
597
  const excludeCtx = excludeIntervalContextFromSteps(steps);
 
598
  dagHandle.beginBatch();
599
  try {
 
600
  steps.forEach((step, i) => {
601
  pushDagFromPreprocess(step, i, true, excludeCtx);
602
  });
@@ -632,9 +700,10 @@ function scheduleDagLastTokenDwell(action: () => void, dwellMs: number = DAG_LAS
632
  }
633
 
634
  /**
635
- * 点击播放时:读界面值并写回规范化结果,得到���轮「相邻两 DAG 更新」之间的延时(ms)。
636
  * - `step`:固定间隔。
637
- * - `total`:`totalS` 按**整段 DAG 步数**均分间隔与「从头回放」相同(`fullStepCount - 1` 段);不管当前 `dagPlaybackNextIndex`首步立即执行,与末 token dwell 无关。
 
638
  */
639
  function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
640
  if (currentDagReplayPacingMode() === 'step') {
@@ -652,7 +721,8 @@ function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
652
  : readStoredDagPlaybackTotalS();
653
  if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(totalS);
654
 
655
- const transitionCount = Math.max(0, fullStepCount - 1);
 
656
  if (transitionCount <= 0) return 0;
657
  return Math.round((totalS * 1000) / transitionCount);
658
  }
@@ -746,7 +816,15 @@ function handleDagPlaybackToggle(wantPlay: boolean): void {
746
  }
747
  scheduleNextPlaybackTick();
748
  };
749
- tick();
 
 
 
 
 
 
 
 
750
  }
751
 
752
  const dagHandle = initGenAttributeDagView(d3.select('#results'), {
@@ -755,7 +833,7 @@ const dagHandle = initGenAttributeDagView(d3.select('#results'), {
755
  stopDagPlayback();
756
  const h = runnerHandle;
757
  if (!h) return;
758
- replayRunnerStepsIntoDag(h);
759
  },
760
  layoutMode: initialDagLayoutMode,
761
  measureWidthPx: initialDagMeasureWidth,
@@ -802,7 +880,7 @@ dagMeasureWidthInput?.addEventListener('change', () => {
802
  const h = runnerHandle;
803
  dagHandle.reset();
804
  if (h && h.tokenCount > 0) {
805
- replayRunnerStepsIntoDag(h);
806
  }
807
  dagHandle.fitViewportToContent();
808
  dagHandle.clearNodeSelection();
@@ -822,7 +900,7 @@ dagCompactnessInput?.addEventListener('change', () => {
822
  const h = runnerHandle;
823
  dagHandle.reset();
824
  if (h && h.tokenCount > 0) {
825
- replayRunnerStepsIntoDag(h);
826
  }
827
  dagHandle.fitViewportToContent();
828
  dagHandle.clearNodeSelection();
@@ -844,7 +922,7 @@ dagEdgeTopPCoverageInput?.addEventListener('change', () => {
844
  const h = runnerHandle;
845
  dagHandle.reset();
846
  if (h && h.tokenCount > 0) {
847
- replayRunnerStepsIntoDag(h);
848
  }
849
  dagHandle.fitViewportToContent();
850
  dagHandle.clearNodeSelection();
@@ -873,7 +951,7 @@ function onExcludePatternsEffectiveChange(): void {
873
  const h = runnerHandle;
874
  if (!h || h.tokenCount === 0) return;
875
  dagHandle.reset();
876
- replayRunnerStepsIntoDag(h);
877
  dagHandle.clearNodeSelection();
878
  }
879
 
@@ -922,7 +1000,13 @@ let lastRunInitialContext = '';
922
  let lastRunInputSnapshot: string | null = null;
923
 
924
  function getInputSnapshotForRun(): string {
925
- const runOpts = { v: currentModelVariant(), max: currentMaxTokens() };
 
 
 
 
 
 
926
  if (isSkipChatTemplate()) {
927
  return JSON.stringify({
928
  mode: 'raw' as const,
@@ -949,6 +1033,15 @@ function setGenLoading(loading: boolean): void {
949
  syncSubmitButtonState();
950
  }
951
 
 
 
 
 
 
 
 
 
 
952
  function syncSubmitButtonState(): void {
953
  if (inFlight) {
954
  submitBtn.text(STOP_BTN_LABEL);
@@ -956,7 +1049,12 @@ function syncSubmitButtonState(): void {
956
  submitBtn.classed('inactive', false);
957
  return;
958
  }
959
- const raw = getActivePromptValue();
 
 
 
 
 
960
  const hasDisplayedRun =
961
  runnerHandle !== null &&
962
  runnerHandle.tokenCount > 0 &&
@@ -964,13 +1062,6 @@ function syncSubmitButtonState(): void {
964
  lastRunInputSnapshot !== null;
965
  const inputMatchesDisplayed =
966
  hasDisplayedRun && getInputSnapshotForRun() === lastRunInputSnapshot;
967
-
968
- if (raw.length === 0) {
969
- submitBtn.text(GENERATE_BTN_LABEL);
970
- submitBtn.property('disabled', true);
971
- submitBtn.classed('inactive', true);
972
- return;
973
- }
974
  if (inputMatchesDisplayed) {
975
  submitBtn.text(tr('Retry'));
976
  submitBtn.property('disabled', false);
@@ -987,6 +1078,7 @@ function bindInputsForSync(): void {
987
  (rawTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
988
  (systemTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
989
  (userTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
 
990
  }
991
 
992
  if (skipChatTemplateInput) {
@@ -1004,6 +1096,11 @@ genAttrUseSystemPromptInput?.addEventListener('change', () => {
1004
  syncGenAttrSystemPromptSuppressedUi();
1005
  syncSubmitButtonState();
1006
  });
 
 
 
 
 
1007
  bindInputsForSync();
1008
  syncSubmitButtonState();
1009
  syncIdleModelMetric();
@@ -1012,6 +1109,7 @@ syncIdleModelMetric();
1012
  const rawTextarea = rawTextField.node() as HTMLTextAreaElement | null;
1013
  const systemPromptTextarea = systemTextField.node() as HTMLTextAreaElement | null;
1014
  const userPromptTextarea = userTextField.node() as HTMLTextAreaElement | null;
 
1015
 
1016
  initQueryHistoryDropdown({
1017
  input: rawTextarea,
@@ -1046,10 +1144,22 @@ initQueryHistoryDropdown({
1046
  applyHistoryOnHover: true,
1047
  });
1048
 
1049
- function syncGenAttrContentUrl(initialContext: string): void {
 
 
 
 
 
 
 
 
 
 
 
 
1050
  replaceDemoUrlParam(null, DEFAULT_DEMO_URL_PARAM, 'gen_attribute');
1051
  replaceContentUrlParam(
1052
- buildCachedContentUrlParam(initialContext),
1053
  DEFAULT_CONTENT_URL_PARAM,
1054
  'gen_attribute'
1055
  );
@@ -1078,24 +1188,61 @@ async function applyGenAttrCachedRun(
1078
  rec: GenAttrCachedRun,
1079
  options: {
1080
  mru?: { shouldTouch: boolean; contentKey: string; ctx?: CachedHistorySelectContext };
1081
- afterUrl: { kind: 'content' } | { kind: 'demo'; slug: string };
1082
  },
1083
  applyGen: number
1084
  ): Promise<void> {
 
 
 
1085
  if (rec.steps.length === 0) {
1086
  showToast(tr('Cached run not found'), 'error');
1087
  return;
1088
  }
1089
- if (isStaleGenAttrCachedApply(applyGen)) {
1090
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
  }
1092
- if (skipChatTemplateInput) {
1093
- skipChatTemplateInput.checked = true;
1094
- writeSkipChatTemplateToStorage(true);
1095
- syncPromptPanelVisibility();
 
 
 
1096
  }
1097
- rawTextField.property('value', rec.initialContext);
1098
- rawTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
 
 
 
 
 
 
 
 
 
 
1099
 
1100
  if (rec.completionReason != null) {
1101
  completeReasonEl.text(completionFinishReasonLabel(rec.completionReason));
@@ -1111,7 +1258,10 @@ async function applyGenAttrCachedRun(
1111
  lastRunInitialContext = rec.initialContext;
1112
  lastRunInputSnapshot = getInputSnapshotForRun();
1113
  syncSubmitButtonState();
1114
- replayRunnerStepsIntoDag(runnerHandle);
 
 
 
1115
  dagHandle.fitViewportToContent();
1116
  dagHandle.clearNodeSelection();
1117
  const n = runnerHandle.tokenCount;
@@ -1136,7 +1286,8 @@ async function applyGenAttrCachedRun(
1136
  return;
1137
  }
1138
  if (options.afterUrl.kind === 'content') {
1139
- syncGenAttrContentUrl(rec.initialContext);
 
1140
  } else {
1141
  syncGenAttrDemoUrl(options.afterUrl.slug);
1142
  }
@@ -1161,7 +1312,7 @@ async function restoreGenAttrFromCachedRun(
1161
  rec,
1162
  {
1163
  mru: shouldTouch ? { shouldTouch: true, contentKey, ctx } : undefined,
1164
- afterUrl: { kind: 'content' },
1165
  },
1166
  applyGen
1167
  );
@@ -1264,6 +1415,7 @@ function showAttributionForStepIndex(idx: number): void {
1264
 
1265
  void (async () => {
1266
  const demoRaw = readDemoUrlParam();
 
1267
  if (demoRaw) {
1268
  const applyGen = nextGenAttrCachedApplyGen();
1269
  let applied = false;
@@ -1311,6 +1463,13 @@ void (async () => {
1311
  replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'gen_attribute');
1312
  },
1313
  });
 
 
 
 
 
 
 
1314
  })();
1315
 
1316
  async function resolveInitialContext(signal: AbortSignal): Promise<string> {
@@ -1321,7 +1480,7 @@ async function resolveInitialContext(signal: AbortSignal): Promise<string> {
1321
  const useSystem = isGenAttrUseSystemPrompt();
1322
  const systemRaw = (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
1323
  const promptReq: { model: string; prompt: string; system?: string } = {
1324
- model: completionModel,
1325
  prompt: user,
1326
  };
1327
  if (useSystem) {
@@ -1331,9 +1490,34 @@ async function resolveInitialContext(signal: AbortSignal): Promise<string> {
1331
  return assembled.prompt_used;
1332
  }
1333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  async function runGeneration(): Promise<void> {
1335
- const prompt = getActivePromptValue();
1336
- if (inFlight || prompt.length === 0) return;
1337
 
1338
  genAbort?.abort();
1339
  genAbort = new AbortController();
@@ -1352,6 +1536,26 @@ async function runGeneration(): Promise<void> {
1352
  let initialContext = '';
1353
 
1354
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1355
  analyzeProgressEl.text('Assembling prompt…').style('display', null);
1356
  initialContext = await resolveInitialContext(signal);
1357
  lastRunInitialContext = initialContext;
@@ -1369,20 +1573,38 @@ async function runGeneration(): Promise<void> {
1369
  }
1370
  }
1371
  }
 
 
 
1372
 
1373
- const maxTokens = currentMaxTokens();
1374
  let initialPromptTokens: number | undefined;
 
1375
  setGenAttrUsageMetric(undefined, 0);
1376
  showProgress(0, maxTokens);
1377
 
1378
  dagHandle.reset();
 
 
 
 
 
 
 
1379
  runnerHandle = startTokenGenAttribution({
1380
  initialContext,
1381
  apiPrefix: apiBaseForRequests,
1382
- model: currentModelVariant(),
1383
  maxTokens,
 
 
1384
  onStep(step, stepIndex) {
1385
- if (stepIndex === 0) initialPromptTokens = initialPromptTokensFromFirstStep(step);
 
 
 
 
 
 
1386
  const h = runnerHandle;
1387
  if (!h) return;
1388
  const excludeCtx = excludeIntervalContextFromSteps(h.getAllSteps());
@@ -1402,9 +1624,18 @@ async function runGeneration(): Promise<void> {
1402
  const stepsToStore = h.getAllSteps();
1403
  const cacheStatus: 'partial' | 'complete' =
1404
  reason === 'stop' || reason === 'length' ? 'complete' : 'partial';
1405
- void save({ initialContext: ic }, stepsToStore, cacheStatus, reason)
 
 
 
 
 
 
 
 
 
1406
  .then(() => genCachedHistory.refreshList())
1407
- .then(() => syncGenAttrContentUrl(ic))
1408
  .catch((e) => console.warn('[gen_attribute] save cached run failed:', e));
1409
  }
1410
  completeReasonEl.text(completionFinishReasonLabel(reason));
@@ -1439,7 +1670,7 @@ submitBtn.on('click', () => {
1439
  void runGeneration();
1440
  });
1441
 
1442
- [rawTextarea, userPromptTextarea].forEach((el) => {
1443
  el?.addEventListener('keydown', (e) => {
1444
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) void runGeneration();
1445
  });
@@ -1452,7 +1683,7 @@ function refreshDagForThemeChange(): void {
1452
  return;
1453
  }
1454
  dagHandle.reset();
1455
- replayRunnerStepsIntoDag(h);
1456
  dagHandle.fitViewportToContent();
1457
  dagHandle.clearNodeSelection();
1458
  }
 
21
  clampDagEdgeTopPCoverage,
22
  DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
23
  extractPromptTokenSpans,
24
+ type PromptTokenSpan,
25
  } from './attribution/genAttributeDagPreprocess';
26
  import {
27
  initGenAttributeDagView,
 
37
  type TokenGenAttributionHandle,
38
  type TokenGenStep,
39
  } from './attribution/tokenGenAttributionRunner';
40
+ import { fetchTokenize } from './attribution/predictionAttributeClient';
41
  import { completionFinishReasonLabel, type CompletionFinishReason } from './utils/generationEndReasonLabel';
42
  import {
43
  buildCachedContentUrlParam,
 
46
  removeCachedEntryByContentKey,
47
  save,
48
  touchCachedEntryByContentKey,
49
+ type GenAttrCacheKey,
50
  } from './storage/genAttributeRunCache';
51
  import { bindExcludeGeneratedPatternsUi, bindExcludePromptPatternsUi } from './attribution/excludePromptPatternsUi';
52
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi';
 
66
  } from './demos/genAttributeBundledDemos';
67
  import { extractErrorMessage } from './utils/errorUtils';
68
  import { exportJsonFile } from './storage/localFileIO';
69
+ import type { GenAttrCachedRun, GenAttrRunDraft } from './storage/genAttributeRunCache';
70
  import {
71
  GEN_ATTR_RAW_INPUT_HISTORY_KEY,
72
  GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY,
73
+ GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY,
74
  GEN_ATTR_USER_INPUT_HISTORY_KEY,
75
  initQueryHistoryDropdown,
76
  saveHistory,
 
80
  writeSkipChatTemplateToStorage,
81
  } from './utils/chatPromptTemplateMode';
82
  import { postCompletionsPrompt, postCompletionsStop } from './api/completionsClient';
 
83
  import { updateApiUsageDisplay, updateModel, validateMetricsElements } from './utils/textMetricsUpdater';
84
 
85
  d3.selectAll('.loadersmall').style('display', 'none');
 
242
  function readStoredDagLayoutMode(): DagLayoutMode {
243
  try {
244
  const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
245
+ if (v === 'text-flow' || v === 'linear-arc' || v === 'spiral') return v;
246
  } catch {
247
  // ignore
248
  }
 
257
  const adminManager = AdminManager.getInstance();
258
  api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
259
 
 
 
 
 
 
 
260
  // --- DOM ---
261
  const rawTextField = d3.select('#gen_attr_raw_text');
262
  const rawTextCountValue = d3.select('#gen_attr_raw_text_count_value');
 
276
  const pasteUserBtn = d3.select('#gen_attr_paste_user_btn');
277
  const userHistoryBtn = document.getElementById('gen_attr_user_history_btn');
278
 
279
+ const teacherForcingTextField = d3.select('#gen_attr_teacher_forcing_text');
280
+ const teacherForcingTextCountValue = d3.select('#gen_attr_teacher_forcing_text_count_value');
281
+ const clearTeacherForcingBtn = d3.select('#gen_attr_clear_teacher_forcing_btn');
282
+ const pasteTeacherForcingBtn = d3.select('#gen_attr_paste_teacher_forcing_btn');
283
+ const teacherForcingHistoryBtn = document.getElementById('gen_attr_teacher_forcing_history_btn');
284
+
285
  const rawInputPanel = document.getElementById('gen_attr_raw_input_panel');
286
  const chatInputPanel = document.getElementById('gen_attr_chat_input_panel');
287
  const skipChatTemplateInput = document.getElementById(
 
291
  'gen_attr_use_system_prompt'
292
  ) as HTMLInputElement | null;
293
  const genAttrSystemPromptPanel = document.getElementById('gen_attr_system_prompt_panel');
294
+ const genAttrTeacherForcingEnable = document.getElementById(
295
+ 'gen_attr_teacher_forcing_enable'
296
+ ) as HTMLInputElement | null;
297
+ const genAttrTeacherForcingBlock = document.getElementById('gen_attr_teacher_forcing_block');
298
+ const genAttrStopAfterTeacherForcing = document.getElementById(
299
+ 'gen_attr_stop_after_teacher_forcing'
300
+ ) as HTMLInputElement | null;
301
 
302
  const submitBtn = d3.select('#gen_attr_submit_btn');
303
  const loaderSmall = d3.select('.loadersmall');
 
350
  }
351
 
352
  function currentDagLayoutMode(): DagLayoutMode {
353
+ const v = dagLayoutModeSelect?.value;
354
+ if (v === 'linear-arc' || v === 'spiral') return v;
355
+ return 'text-flow';
356
  }
357
 
358
  function applyDagLayoutModeUi(): void {
359
+ const mode = currentDagLayoutMode();
360
  if (dagCompactnessGroup) {
361
+ /** text-flow / spiral 均使用 display-scale 驱动的节点宽高与边回缩;linear-arc 不适用。 */
362
+ dagCompactnessGroup.hidden = mode === 'linear-arc';
363
  }
364
  if (dagMeasureWidthGroup) {
365
+ dagMeasureWidthGroup.hidden = mode !== 'text-flow';
366
  }
367
  if (dagLinearArcIntervalGroup) {
368
+ dagLinearArcIntervalGroup.hidden = mode !== 'linear-arc';
369
  }
370
  }
371
 
 
539
  return (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
540
  }
541
 
542
+ function setActivePromptValue(value: string): void {
543
+ if (isSkipChatTemplate()) {
544
+ rawTextField.property('value', value);
545
+ rawTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
546
+ return;
547
+ }
548
+ userTextField.property('value', value);
549
+ userPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
550
+ }
551
+
552
+ function isGenAttrTeacherForcingUiOn(): boolean {
553
+ return genAttrTeacherForcingEnable?.checked ?? false;
554
+ }
555
+
556
+ function isStopAfterTeacherForcingOn(): boolean {
557
+ return genAttrStopAfterTeacherForcing?.checked ?? false;
558
+ }
559
+
560
+ /** 勾选 Teacher forcing 且续写非空时返回原文;未勾选或空串时返回 `undefined`。 */
561
+ function teacherForcingContinuationForRun(): string | undefined {
562
+ if (!isGenAttrTeacherForcingUiOn()) return undefined;
563
+ const t = (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
564
+ return t.length > 0 ? t : undefined;
565
+ }
566
+
567
+ function syncTeacherForcingRow(): void {
568
+ if (genAttrTeacherForcingBlock) {
569
+ genAttrTeacherForcingBlock.hidden = !isGenAttrTeacherForcingUiOn();
570
+ }
571
+ }
572
+
573
  new TextInputController({
574
  textField: rawTextField,
575
  textCountValue: rawTextCountValue,
 
603
  showAlertDialog,
604
  });
605
 
606
+ new TextInputController({
607
+ textField: teacherForcingTextField,
608
+ textCountValue: teacherForcingTextCountValue,
609
+ clearBtn: clearTeacherForcingBtn,
610
+ submitBtn,
611
+ saveBtn: d3.select(null),
612
+ pasteBtn: pasteTeacherForcingBtn,
613
+ totalSurprisalFormat,
614
+ showAlertDialog,
615
+ });
616
+
617
  /** 与 DAG 节点 offset 同源的累积串,供跨 token 闭合后的排除区间(`excludeNodeAggregatedEntries`)。 */
618
  function excludeIntervalContextFromSteps(steps: TokenGenStep[]): string | undefined {
619
  if (steps.length === 0) return undefined;
 
629
  excludeIntervalContext?: string,
630
  ): void {
631
  if (stepIndex === 0) {
632
+ if (!dagHandle.hasPromptSpans()) {
633
+ dagHandle.setPromptTokenSpans(extractPromptTokenSpans(step), step.context);
634
+ }
635
  if (!dagHandle.isBatching() && fitOnFirstStep) {
636
  dagHandle.fitViewportToContent();
637
  }
 
642
  /** 下一步要 `pushDagFromPreprocess` 的步下标;与当前 DAG 前缀一致(暂停不重置) */
643
  let dagPlaybackNextIndex = 0;
644
 
645
+ /**
646
+ * 当前 run 的 prompt token spans:tokenize 先行写入,或 step 0 归因兜底,或历史加载时赋值。
647
+ * 步进回放从头开始时作为 prompt 帧数据源,独立于 token_attribution 完整性。
648
+ */
649
+ let currentRunPromptSpans: PromptTokenSpan[] = [];
650
+
651
+ /**
652
+ * 将 handle 中已存步序按序重放进 DAG(调用方负责先 {@link dagHandle.reset} 等)。
653
+ * @param promptSpans prompt 层节点数据;在批内最先注入,与归因裁剪无关。
654
+ * 未传入时从 step 0 归因降级(旧缓存 / 非生成路径兼容)。
655
+ */
656
+ function replayRunnerStepsIntoDag(h: TokenGenAttributionHandle, promptSpans?: PromptTokenSpan[]): void {
657
  if (h.tokenCount === 0) {
658
  dagPlaybackNextIndex = 0;
659
  return;
660
  }
 
 
661
  const steps = h.getAllSteps();
662
+ const spans = promptSpans ?? extractPromptTokenSpans(steps[0]!);
663
  const excludeCtx = excludeIntervalContextFromSteps(steps);
664
+ // 整段回放期间中间帧不可见:批处理内只维护图数据,结束时统一刷一次 svg。
665
  dagHandle.beginBatch();
666
  try {
667
+ dagHandle.setPromptTokenSpans(spans, steps[0]!.context);
668
  steps.forEach((step, i) => {
669
  pushDagFromPreprocess(step, i, true, excludeCtx);
670
  });
 
700
  }
701
 
702
  /**
703
+ * 点击播放时:读界面值并写回规范化结果,得到轮「相邻两 DAG 更新」之间的延时(ms)。
704
  * - `step`:固定间隔。
705
+ * - `total`:`totalS` 按**整段帧数(含 prompt 帧)**均分,`fullStepCount` 段等权间隔
706
+ * `fullStepCount` 即生成 token 步数;prompt 帧 → step0 占一段,step0 → step1 占一段,依此类推。
707
  */
708
  function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
709
  if (currentDagReplayPacingMode() === 'step') {
 
721
  : readStoredDagPlaybackTotalS();
722
  if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(totalS);
723
 
724
+ // prompt 帧作为等权第一段,共 fullStepCount 段(比原来的 fullStepCount-1 多一段)
725
+ const transitionCount = Math.max(0, fullStepCount);
726
  if (transitionCount <= 0) return 0;
727
  return Math.round((totalS * 1000) / transitionCount);
728
  }
 
816
  }
817
  scheduleNextPlaybackTick();
818
  };
819
+ // 从头开始(index 为 0)时先展示 prompt 帧,再等一个步进间隔后触发 step 0;
820
+ // 中途恢复(index > 0)则直接续播,不重复 prompt 帧。
821
+ if (dagPlaybackNextIndex === 0 && currentRunPromptSpans.length > 0) {
822
+ dagHandle.setPromptTokenSpans(currentRunPromptSpans, steps[0]!.context);
823
+ dagHandle.fitViewportToContent();
824
+ scheduleNextPlaybackTick();
825
+ } else {
826
+ tick();
827
+ }
828
  }
829
 
830
  const dagHandle = initGenAttributeDagView(d3.select('#results'), {
 
833
  stopDagPlayback();
834
  const h = runnerHandle;
835
  if (!h) return;
836
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
837
  },
838
  layoutMode: initialDagLayoutMode,
839
  measureWidthPx: initialDagMeasureWidth,
 
880
  const h = runnerHandle;
881
  dagHandle.reset();
882
  if (h && h.tokenCount > 0) {
883
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
884
  }
885
  dagHandle.fitViewportToContent();
886
  dagHandle.clearNodeSelection();
 
900
  const h = runnerHandle;
901
  dagHandle.reset();
902
  if (h && h.tokenCount > 0) {
903
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
904
  }
905
  dagHandle.fitViewportToContent();
906
  dagHandle.clearNodeSelection();
 
922
  const h = runnerHandle;
923
  dagHandle.reset();
924
  if (h && h.tokenCount > 0) {
925
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
926
  }
927
  dagHandle.fitViewportToContent();
928
  dagHandle.clearNodeSelection();
 
951
  const h = runnerHandle;
952
  if (!h || h.tokenCount === 0) return;
953
  dagHandle.reset();
954
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
955
  dagHandle.clearNodeSelection();
956
  }
957
 
 
1000
  let lastRunInputSnapshot: string | null = null;
1001
 
1002
  function getInputSnapshotForRun(): string {
1003
+ const runOpts = {
1004
+ v: currentModelVariant(),
1005
+ max: currentMaxTokens(),
1006
+ tfOn: isGenAttrTeacherForcingUiOn(),
1007
+ tfText: (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1008
+ saOn: isStopAfterTeacherForcingOn(),
1009
+ };
1010
  if (isSkipChatTemplate()) {
1011
  return JSON.stringify({
1012
  mode: 'raw' as const,
 
1033
  syncSubmitButtonState();
1034
  }
1035
 
1036
+ /** 当前输入是否满足可以发起一次生成(不含 inFlight 判断)。 */
1037
+ function isInputReadyForRun(): boolean {
1038
+ const prompt = getActivePromptValue();
1039
+ const forcing = teacherForcingContinuationForRun();
1040
+ if (prompt.length === 0 && forcing === undefined) return false;
1041
+ if (prompt.length > 0 && isGenAttrTeacherForcingUiOn() && forcing === undefined) return false;
1042
+ return true;
1043
+ }
1044
+
1045
  function syncSubmitButtonState(): void {
1046
  if (inFlight) {
1047
  submitBtn.text(STOP_BTN_LABEL);
 
1049
  submitBtn.classed('inactive', false);
1050
  return;
1051
  }
1052
+ if (!isInputReadyForRun()) {
1053
+ submitBtn.text(GENERATE_BTN_LABEL);
1054
+ submitBtn.property('disabled', true);
1055
+ submitBtn.classed('inactive', true);
1056
+ return;
1057
+ }
1058
  const hasDisplayedRun =
1059
  runnerHandle !== null &&
1060
  runnerHandle.tokenCount > 0 &&
 
1062
  lastRunInputSnapshot !== null;
1063
  const inputMatchesDisplayed =
1064
  hasDisplayedRun && getInputSnapshotForRun() === lastRunInputSnapshot;
 
 
 
 
 
 
 
1065
  if (inputMatchesDisplayed) {
1066
  submitBtn.text(tr('Retry'));
1067
  submitBtn.property('disabled', false);
 
1078
  (rawTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
1079
  (systemTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
1080
  (userTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
1081
+ (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.addEventListener('input', onInput);
1082
  }
1083
 
1084
  if (skipChatTemplateInput) {
 
1096
  syncGenAttrSystemPromptSuppressedUi();
1097
  syncSubmitButtonState();
1098
  });
1099
+ genAttrTeacherForcingEnable?.addEventListener('change', () => {
1100
+ syncTeacherForcingRow();
1101
+ syncSubmitButtonState();
1102
+ });
1103
+ syncTeacherForcingRow();
1104
  bindInputsForSync();
1105
  syncSubmitButtonState();
1106
  syncIdleModelMetric();
 
1109
  const rawTextarea = rawTextField.node() as HTMLTextAreaElement | null;
1110
  const systemPromptTextarea = systemTextField.node() as HTMLTextAreaElement | null;
1111
  const userPromptTextarea = userTextField.node() as HTMLTextAreaElement | null;
1112
+ const teacherForcingTextarea = teacherForcingTextField.node() as HTMLTextAreaElement | null;
1113
 
1114
  initQueryHistoryDropdown({
1115
  input: rawTextarea,
 
1144
  applyHistoryOnHover: true,
1145
  });
1146
 
1147
+ initQueryHistoryDropdown({
1148
+ input: teacherForcingTextarea,
1149
+ dropdownId: 'gen_attr_teacher_forcing_history_dropdown',
1150
+ storageKey: GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY,
1151
+ openDropdownOnFocusInput: false,
1152
+ filterHistoryByInput: false,
1153
+ onSelect: syncSubmitButtonState,
1154
+ historyButton: teacherForcingHistoryBtn,
1155
+ applyHistoryOnHover: true,
1156
+ });
1157
+
1158
+
1159
+ function syncGenAttrContentUrl(key: GenAttrCacheKey): void {
1160
  replaceDemoUrlParam(null, DEFAULT_DEMO_URL_PARAM, 'gen_attribute');
1161
  replaceContentUrlParam(
1162
+ buildCachedContentUrlParam(key),
1163
  DEFAULT_CONTENT_URL_PARAM,
1164
  'gen_attribute'
1165
  );
 
1188
  rec: GenAttrCachedRun,
1189
  options: {
1190
  mru?: { shouldTouch: boolean; contentKey: string; ctx?: CachedHistorySelectContext };
1191
+ afterUrl: { kind: 'content'; contentKey: string } | { kind: 'demo'; slug: string };
1192
  },
1193
  applyGen: number
1194
  ): Promise<void> {
1195
+ if (isStaleGenAttrCachedApply(applyGen)) {
1196
+ return;
1197
+ }
1198
  if (rec.steps.length === 0) {
1199
  showToast(tr('Cached run not found'), 'error');
1200
  return;
1201
  }
1202
+ const { draft } = rec;
1203
+ if (draft?.mode === 'chat') {
1204
+ if (genAttrUseSystemPromptInput) {
1205
+ genAttrUseSystemPromptInput.checked = draft.useSystem ?? true;
1206
+ }
1207
+ if (skipChatTemplateInput) {
1208
+ skipChatTemplateInput.checked = false;
1209
+ writeSkipChatTemplateToStorage(false);
1210
+ syncPromptPanelVisibility();
1211
+ syncGenAttrSystemPromptSuppressedUi();
1212
+ }
1213
+ systemTextField.property('value', draft.system ?? '');
1214
+ systemPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1215
+ userTextField.property('value', draft.user ?? '');
1216
+ userPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1217
+ } else {
1218
+ if (skipChatTemplateInput) {
1219
+ skipChatTemplateInput.checked = true;
1220
+ writeSkipChatTemplateToStorage(true);
1221
+ syncPromptPanelVisibility();
1222
+ }
1223
+ rawTextField.property('value', rec.initialContext);
1224
+ rawTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1225
  }
1226
+
1227
+ // 恢复 model / maxTokens(必须在 getInputSnapshotForRun() 之前,使快照与实际一致)
1228
+ if (draft?.model && modelVariantSelect) {
1229
+ modelVariantSelect.value = draft.model;
1230
+ }
1231
+ if (draft?.maxTokens != null && maxTokensInput) {
1232
+ maxTokensInput.value = String(draft.maxTokens);
1233
  }
1234
+
1235
+ // 恢复 teacher forcing 状态
1236
+ const tfFromRec = draft?.teacherForcing ?? '';
1237
+ if (genAttrTeacherForcingEnable) {
1238
+ genAttrTeacherForcingEnable.checked = tfFromRec.length > 0;
1239
+ }
1240
+ if (genAttrStopAfterTeacherForcing) {
1241
+ genAttrStopAfterTeacherForcing.checked = draft?.stopAfterTeacherForcing ?? false;
1242
+ }
1243
+ teacherForcingTextField.property('value', tfFromRec);
1244
+ teacherForcingTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1245
+ syncTeacherForcingRow();
1246
 
1247
  if (rec.completionReason != null) {
1248
  completeReasonEl.text(completionFinishReasonLabel(rec.completionReason));
 
1258
  lastRunInitialContext = rec.initialContext;
1259
  lastRunInputSnapshot = getInputSnapshotForRun();
1260
  syncSubmitButtonState();
1261
+ // 新缓存直接用 promptSpans;旧缓存无此字段时从 step 0 归因降级
1262
+ const replayPromptSpans = rec.promptSpans ?? extractPromptTokenSpans(rec.steps[0]!);
1263
+ currentRunPromptSpans = replayPromptSpans;
1264
+ replayRunnerStepsIntoDag(runnerHandle, replayPromptSpans);
1265
  dagHandle.fitViewportToContent();
1266
  dagHandle.clearNodeSelection();
1267
  const n = runnerHandle.tokenCount;
 
1286
  return;
1287
  }
1288
  if (options.afterUrl.kind === 'content') {
1289
+ replaceDemoUrlParam(null, DEFAULT_DEMO_URL_PARAM, 'gen_attribute');
1290
+ replaceContentUrlParam(options.afterUrl.contentKey, DEFAULT_CONTENT_URL_PARAM, 'gen_attribute');
1291
  } else {
1292
  syncGenAttrDemoUrl(options.afterUrl.slug);
1293
  }
 
1312
  rec,
1313
  {
1314
  mru: shouldTouch ? { shouldTouch: true, contentKey, ctx } : undefined,
1315
+ afterUrl: { kind: 'content', contentKey },
1316
  },
1317
  applyGen
1318
  );
 
1415
 
1416
  void (async () => {
1417
  const demoRaw = readDemoUrlParam();
1418
+ const contentRaw = readContentUrlParam();
1419
  if (demoRaw) {
1420
  const applyGen = nextGenAttrCachedApplyGen();
1421
  let applied = false;
 
1463
  replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'gen_attribute');
1464
  },
1465
  });
1466
+ // 无任何 URL 参数时,静默恢复最近一次缓存 run(输入框与 DAG 一并还原)
1467
+ if (!demoRaw && !contentRaw) {
1468
+ const rows = await listCachedHistoryRows();
1469
+ if (rows.length > 0) {
1470
+ await restoreGenAttrFromCachedRun(rows[0]!.contentKey, false);
1471
+ }
1472
+ }
1473
  })();
1474
 
1475
  async function resolveInitialContext(signal: AbortSignal): Promise<string> {
 
1480
  const useSystem = isGenAttrUseSystemPrompt();
1481
  const systemRaw = (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
1482
  const promptReq: { model: string; prompt: string; system?: string } = {
1483
+ model: currentModelVariant(),
1484
  prompt: user,
1485
  };
1486
  if (useSystem) {
 
1490
  return assembled.prompt_used;
1491
  }
1492
 
1493
+ async function autoMoveFirstTeacherForcingTokenToPromptIfNeeded(): Promise<void> {
1494
+ if (!isSkipChatTemplate()) return;
1495
+ if (getActivePromptValue().length > 0) return;
1496
+ const forcing = teacherForcingContinuationForRun();
1497
+ if (forcing === undefined) return;
1498
+
1499
+ const spans = await fetchTokenize(apiBaseForRequests, forcing, currentModelVariant());
1500
+ if (!spans.length) {
1501
+ throw new Error('Teacher forcing tokenize returned empty spans.');
1502
+ }
1503
+ const first = spans[0]!;
1504
+ const [start, end] = first.offset;
1505
+ const chars = Array.from(forcing);
1506
+ if (start < 0 || end <= start || end > chars.length) {
1507
+ throw new Error(
1508
+ `Teacher forcing tokenize returned invalid first span [${start}, ${end}) for continuation.`
1509
+ );
1510
+ }
1511
+ const movedPrompt = chars.slice(start, end).join('');
1512
+ const remainingForcing = chars.slice(end).join('');
1513
+
1514
+ setActivePromptValue(movedPrompt);
1515
+ teacherForcingTextField.property('value', remainingForcing);
1516
+ teacherForcingTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1517
+ }
1518
+
1519
  async function runGeneration(): Promise<void> {
1520
+ if (inFlight || !isInputReadyForRun()) return;
 
1521
 
1522
  genAbort?.abort();
1523
  genAbort = new AbortController();
 
1536
  let initialContext = '';
1537
 
1538
  try {
1539
+ await autoMoveFirstTeacherForcingTokenToPromptIfNeeded();
1540
+ const teacherForcingText = teacherForcingContinuationForRun();
1541
+ const stopAfterTF = isStopAfterTeacherForcingOn();
1542
+ const maxTokens = currentMaxTokens();
1543
+ const tokenizeModel = currentModelVariant();
1544
+ const tfDraftFields = teacherForcingText !== undefined
1545
+ ? { teacherForcing: teacherForcingText, stopAfterTeacherForcing: stopAfterTF }
1546
+ : {};
1547
+ const runDraft: GenAttrRunDraft = isSkipChatTemplate()
1548
+ ? { mode: 'raw', model: tokenizeModel, maxTokens, ...tfDraftFields }
1549
+ : {
1550
+ mode: 'chat',
1551
+ model: tokenizeModel,
1552
+ maxTokens,
1553
+ system: systemPromptTextarea?.value ?? '',
1554
+ user: userPromptTextarea?.value ?? '',
1555
+ useSystem: isGenAttrUseSystemPrompt(),
1556
+ ...tfDraftFields,
1557
+ };
1558
+ const prompt = getActivePromptValue();
1559
  analyzeProgressEl.text('Assembling prompt…').style('display', null);
1560
  initialContext = await resolveInitialContext(signal);
1561
  lastRunInitialContext = initialContext;
 
1573
  }
1574
  }
1575
  }
1576
+ if (teacherForcingText !== undefined) {
1577
+ saveHistory(teacherForcingText, GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY);
1578
+ }
1579
 
 
1580
  let initialPromptTokens: number | undefined;
1581
+ currentRunPromptSpans = [];
1582
  setGenAttrUsageMetric(undefined, 0);
1583
  showProgress(0, maxTokens);
1584
 
1585
  dagHandle.reset();
1586
+ void fetchTokenize(apiBaseForRequests, initialContext, tokenizeModel).then((spans) => {
1587
+ currentRunPromptSpans = spans;
1588
+ if (spans.length > 0) {
1589
+ dagHandle.setPromptTokenSpans(spans, initialContext);
1590
+ dagHandle.fitViewportToContent();
1591
+ }
1592
+ }).catch(() => { /* 失败静默,step 0 回调兜底 */ });
1593
  runnerHandle = startTokenGenAttribution({
1594
  initialContext,
1595
  apiPrefix: apiBaseForRequests,
1596
+ model: tokenizeModel,
1597
  maxTokens,
1598
+ teacherForcingContinuation: teacherForcingText,
1599
+ stopAfterTeacherForcing: stopAfterTF,
1600
  onStep(step, stepIndex) {
1601
+ if (stepIndex === 0) {
1602
+ initialPromptTokens = initialPromptTokensFromFirstStep(step);
1603
+ // tokenize 失败时兜底:从 step 0 归因派生 spans
1604
+ if (currentRunPromptSpans.length === 0) {
1605
+ currentRunPromptSpans = extractPromptTokenSpans(step);
1606
+ }
1607
+ }
1608
  const h = runnerHandle;
1609
  if (!h) return;
1610
  const excludeCtx = excludeIntervalContextFromSteps(h.getAllSteps());
 
1624
  const stepsToStore = h.getAllSteps();
1625
  const cacheStatus: 'partial' | 'complete' =
1626
  reason === 'stop' || reason === 'length' ? 'complete' : 'partial';
1627
+ const cacheKey: GenAttrCacheKey = {
1628
+ initialContext: ic,
1629
+ model: tokenizeModel,
1630
+ maxTokens,
1631
+ ...(teacherForcingText !== undefined ? {
1632
+ teacherForcing: teacherForcingText,
1633
+ stopAfterTeacherForcing: stopAfterTF,
1634
+ } : {}),
1635
+ };
1636
+ void save(cacheKey, stepsToStore, currentRunPromptSpans, cacheStatus, reason, runDraft)
1637
  .then(() => genCachedHistory.refreshList())
1638
+ .then(() => syncGenAttrContentUrl(cacheKey))
1639
  .catch((e) => console.warn('[gen_attribute] save cached run failed:', e));
1640
  }
1641
  completeReasonEl.text(completionFinishReasonLabel(reason));
 
1670
  void runGeneration();
1671
  });
1672
 
1673
+ [rawTextarea, userPromptTextarea, teacherForcingTextarea].forEach((el) => {
1674
  el?.addEventListener('keydown', (e) => {
1675
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) void runGeneration();
1676
  });
 
1683
  return;
1684
  }
1685
  dagHandle.reset();
1686
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1687
  dagHandle.fitViewportToContent();
1688
  dagHandle.clearNodeSelection();
1689
  }
client/src/ts/lang/translations.ts CHANGED
@@ -116,6 +116,15 @@ export const translations: Translations = {
116
  'History': '输入历史',
117
  'Raw prompt': 'Raw prompt 原始提示词',
118
  'Raw prompt mode': 'Raw prompt mode 原始提示词模式',
 
 
 
 
 
 
 
 
 
119
  'Ask': '提问',
120
  'Force retry': '强制重试',
121
  'Retry': '重试',
 
116
  'History': '输入历史',
117
  'Raw prompt': 'Raw prompt 原始提示词',
118
  'Raw prompt mode': 'Raw prompt mode 原始提示词模式',
119
+ 'Teacher forcing': 'Teacher forcing 强制续写归因',
120
+ 'Forced continuation': 'Forced continuation 期望续写',
121
+ 'Stop after teacher forcing': '续写结束后停止(不继续 top-1 生成)',
122
+ 'When enabled, type the exact continuation after the assembled prompt. Each step attributes the next token toward that text (same tokenizer as Model), then stops when the continuation is consumed or EOS.':
123
+ '启用后,在下方填写接在「完整 prompt」之后的期望续写文本。每一步用该串剩余部分的第一个 token 作为归因目标(与所选 Model 槽位分词器一致);续写消费完或遇到 EOS 时结束。',
124
+ 'Expected generated text after the full prompt. Each API step uses the first token of what remains here as the attribution target.':
125
+ '期望模型在完整 prompt 之后生成的文字;每一步对当前剩余串的第一个 token 做归因目标。',
126
+ 'When unchecked, generation continues with top-1 after teacher forcing tokens are exhausted, up to Max tokens.':
127
+ '未勾选时,teacher forcing 续写用完后将继续以 top-1 贪心生成,直到 Max tokens 或 EOS。',
128
  'Ask': '提问',
129
  'Force retry': '强制重试',
130
  'Retry': '重试',
client/src/ts/storage/genAttributeRunCache.ts CHANGED
@@ -1,4 +1,5 @@
1
  import type { TokenGenStep } from '../attribution/tokenGenAttributionRunner';
 
2
  import {
3
  canonicalizeCompletionFinishReason,
4
  isCompletionFinishReason,
@@ -17,26 +18,72 @@ import {
17
  const NAMESPACE = 'gen_attr';
18
  const MAX_ENTRIES = 50;
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  export type GenAttrCachedRun = {
21
  initialContext: string;
22
  steps: TokenGenStep[];
 
 
23
  /** 与 OpenAI `finish_reason` 子集一致,见 {@link CompletionFinishReason} */
24
  completionReason?: CompletionFinishReason;
 
 
25
  };
26
 
 
 
 
 
27
  export type GenAttrCacheKey = {
28
  initialContext: string;
 
 
 
 
 
 
29
  };
30
 
31
- function keyHashForContext(initialContext: string): string {
32
- return buildContentKeyFromBusinessKey({ initialContext });
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
  export async function save(
36
  key: GenAttrCacheKey,
37
  steps: TokenGenStep[],
 
38
  status: 'partial' | 'complete' = steps.length > 0 ? 'partial' : 'complete',
39
- completionReason?: CompletionFinishReason
 
40
  ): Promise<void> {
41
  const { initialContext } = key;
42
  let reasonToStore: CompletionFinishReason | undefined;
@@ -50,11 +97,13 @@ export async function save(
50
  const payload: GenAttrCachedRun = {
51
  initialContext,
52
  steps,
 
53
  ...(reasonToStore !== undefined ? { completionReason: reasonToStore } : {}),
 
54
  };
55
  await upsertEntry({
56
  namespace: NAMESPACE,
57
- businessKeyJson: JSON.stringify({ initialContext }),
58
  listLabel: initialContext,
59
  payload,
60
  status,
@@ -63,7 +112,7 @@ export async function save(
63
  }
64
 
65
  export async function get(key: GenAttrCacheKey): Promise<GenAttrCachedRun | undefined> {
66
- const row = await getByContentKey<GenAttrCachedRun>(NAMESPACE, keyHashForContext(key.initialContext));
67
  return row?.payload;
68
  }
69
 
@@ -73,8 +122,8 @@ export async function getCachedEntryByContentKey(raw: string): Promise<GenAttrCa
73
  return row?.payload;
74
  }
75
 
76
- export function buildCachedContentUrlParam(initialContext: string): string {
77
- return keyHashForContext(initialContext);
78
  }
79
 
80
  export async function removeCachedEntryByContentKey(contentKey: string): Promise<void> {
 
1
  import type { TokenGenStep } from '../attribution/tokenGenAttributionRunner';
2
+ import type { PromptTokenSpan } from '../attribution/genAttributeDagPreprocess';
3
  import {
4
  canonicalizeCompletionFinishReason,
5
  isCompletionFinishReason,
 
18
  const NAMESPACE = 'gen_attr';
19
  const MAX_ENTRIES = 50;
20
 
21
+ /** 生成时左侧输入面板的状态快照,随缓存一起存储,加载缓存时据此还原输入模式与内容。 */
22
+ export type GenAttrRunDraft = {
23
+ mode: 'raw' | 'chat';
24
+ /** 生成所用的 model 槽位 */
25
+ model?: string;
26
+ /** 生成时的 maxTokens 上限 */
27
+ maxTokens?: number;
28
+ /** chat 模式:system prompt 原文 */
29
+ system?: string;
30
+ /** chat 模式:user prompt 原文 */
31
+ user?: string;
32
+ /** chat 模式:是否启用 system prompt */
33
+ useSystem?: boolean;
34
+ /** Teacher forcing 续写原文;非空则表示已启用 teacher forcing。旧缓存无此字段时从根级 teacherForcingContinuation 降级读取。 */
35
+ teacherForcing?: string;
36
+ /** teacher forcing 结束后是否停止(而非继续 top-1 生成)。 */
37
+ stopAfterTeacherForcing?: boolean;
38
+ };
39
+
40
  export type GenAttrCachedRun = {
41
  initialContext: string;
42
  steps: TokenGenStep[];
43
+ /** 完整 prompt token spans(offset + raw),与 /api/tokenize 同源;旧缓存无此字段时由调用方从 step 0 归因降级。 */
44
+ promptSpans?: PromptTokenSpan[];
45
  /** 与 OpenAI `finish_reason` 子集一致,见 {@link CompletionFinishReason} */
46
  completionReason?: CompletionFinishReason;
47
+ /** 生成时输入面板快照;旧缓存无此字段时回退到 raw 模式展示 initialContext。 */
48
+ draft?: GenAttrRunDraft;
49
  };
50
 
51
+ /**
52
+ * 缓存业务 key:涵盖所有影响 steps 内容的生成参数。
53
+ * 原则:draft 中存储的可变参数均纳入 key,同参数不同结果不应互相覆盖。
54
+ */
55
  export type GenAttrCacheKey = {
56
  initialContext: string;
57
+ model: string;
58
+ maxTokens: number;
59
+ /** teacher forcing 续写文本,无则省略 */
60
+ teacherForcing?: string;
61
+ /** teacher forcing 用尽后是否停止,仅在 teacherForcing 非空时有意义 */
62
+ stopAfterTeacherForcing?: boolean;
63
  };
64
 
65
+ /** 规范化 key,去除对结果无影响的冗余字段,保证相同语义的 key 生成相同 hash。 */
66
+ function normalizeKey(key: GenAttrCacheKey): object {
67
+ const tf = key.teacherForcing && key.teacherForcing.length > 0 ? key.teacherForcing : undefined;
68
+ return {
69
+ initialContext: key.initialContext,
70
+ model: key.model,
71
+ maxTokens: key.maxTokens,
72
+ ...(tf !== undefined ? { teacherForcing: tf, stopAfterTeacherForcing: key.stopAfterTeacherForcing ?? false } : {}),
73
+ };
74
+ }
75
+
76
+ function keyHash(key: GenAttrCacheKey): string {
77
+ return buildContentKeyFromBusinessKey(normalizeKey(key));
78
  }
79
 
80
  export async function save(
81
  key: GenAttrCacheKey,
82
  steps: TokenGenStep[],
83
+ promptSpans: PromptTokenSpan[],
84
  status: 'partial' | 'complete' = steps.length > 0 ? 'partial' : 'complete',
85
+ completionReason?: CompletionFinishReason,
86
+ draft?: GenAttrRunDraft
87
  ): Promise<void> {
88
  const { initialContext } = key;
89
  let reasonToStore: CompletionFinishReason | undefined;
 
97
  const payload: GenAttrCachedRun = {
98
  initialContext,
99
  steps,
100
+ ...(promptSpans.length > 0 ? { promptSpans } : {}),
101
  ...(reasonToStore !== undefined ? { completionReason: reasonToStore } : {}),
102
+ ...(draft !== undefined ? { draft } : {}),
103
  };
104
  await upsertEntry({
105
  namespace: NAMESPACE,
106
+ businessKeyJson: JSON.stringify(normalizeKey(key)),
107
  listLabel: initialContext,
108
  payload,
109
  status,
 
112
  }
113
 
114
  export async function get(key: GenAttrCacheKey): Promise<GenAttrCachedRun | undefined> {
115
+ const row = await getByContentKey<GenAttrCachedRun>(NAMESPACE, keyHash(key));
116
  return row?.payload;
117
  }
118
 
 
122
  return row?.payload;
123
  }
124
 
125
+ export function buildCachedContentUrlParam(key: GenAttrCacheKey): string {
126
+ return keyHash(key);
127
  }
128
 
129
  export async function removeCachedEntryByContentKey(contentKey: string): Promise<void> {
client/src/ts/utils/queryHistory.ts CHANGED
@@ -18,6 +18,8 @@ export const GEN_ATTR_RAW_INPUT_HISTORY_KEY = 'info_radar_gen_attr_raw_input_his
18
  export const GEN_ATTR_USER_INPUT_HISTORY_KEY = 'info_radar_gen_attr_user_input_history';
19
  /** Generate & Attribute 页 System 输入框 */
20
  export const GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_gen_attr_system_input_history';
 
 
21
 
22
  const MAX = 100;
23
 
 
18
  export const GEN_ATTR_USER_INPUT_HISTORY_KEY = 'info_radar_gen_attr_user_input_history';
19
  /** Generate & Attribute 页 System 输入框 */
20
  export const GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_gen_attr_system_input_history';
21
+ /** Generate & Attribute 页 Teacher forcing 续写框 */
22
+ export const GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY = 'info_radar_gen_attr_teacher_forcing_input_history';
23
 
24
  const MAX = 100;
25
 
client/src/ts/utils/topkChartUtils.ts CHANGED
@@ -13,6 +13,14 @@
13
  import * as d3 from 'd3';
14
  import { processCandidateText } from './tokenDisplayUtils';
15
 
 
 
 
 
 
 
 
 
16
  /** Tooltip 默认条形宽度 */
17
  const MAX_BAR_WIDTH = 60;
18
  /** Semantic debug 专用:更大条形与列宽,tooltip 不受影响 */
@@ -113,7 +121,7 @@ export function renderTopkChartHtml(
113
  if (!data.length) return '';
114
 
115
  const maxBar = options?.maxBarWidth ?? MAX_BAR_WIDTH;
116
- const numF = options?.numFormat ?? ((v: number) => d3.format('.3g')(v * 100) + '%');
117
 
118
  const maxProb = data[0]?.prob ?? 1;
119
  const scale = d3.scaleLinear().domain([0, maxProb]).range([0, maxBar]);
 
13
  import * as d3 from 'd3';
14
  import { processCandidateText } from './tokenDisplayUtils';
15
 
16
+ /**
17
+ * 与 analysis.html 主视图 Tooltip 中 Top-K 条形图概率列一致({@link renderTopkChartHtml} 默认格式)。
18
+ * @param v 模型给出的概率,区间 [0, 1]
19
+ */
20
+ export function formatTopkTooltipProbabilityPercent(v: number): string {
21
+ return d3.format('.3g')(v * 100) + '%';
22
+ }
23
+
24
  /** Tooltip 默认条形宽度 */
25
  const MAX_BAR_WIDTH = 60;
26
  /** Semantic debug 专用:更大条形与列宽,tooltip 不受影响 */
 
121
  if (!data.length) return '';
122
 
123
  const maxBar = options?.maxBarWidth ?? MAX_BAR_WIDTH;
124
+ const numF = options?.numFormat ?? formatTopkTooltipProbabilityPercent;
125
 
126
  const maxProb = data[0]?.prob ?? 1;
127
  const scale = d3.scaleLinear().domain([0, maxProb]).range([0, maxBar]);
server.py CHANGED
@@ -38,6 +38,7 @@ from backend.api.fetch_url import fetch_url # noqa: F401
38
  from backend.api.client_activity import client_activity_report # noqa: F401
39
  from backend.api.analyze_semantic import analyze_semantic # noqa: F401
40
  from backend.api.prediction_attribute import prediction_attribute # noqa: F401
 
41
  from backend.api.model_switch import ( # noqa: F401
42
  get_available_models,
43
  get_current_model,
 
38
  from backend.api.client_activity import client_activity_report # noqa: F401
39
  from backend.api.analyze_semantic import analyze_semantic # noqa: F401
40
  from backend.api.prediction_attribute import prediction_attribute # noqa: F401
41
+ from backend.api.tokenize import tokenize # noqa: F401
42
  from backend.api.model_switch import ( # noqa: F401
43
  get_available_models,
44
  get_current_model,
server.yaml CHANGED
@@ -562,6 +562,56 @@ paths:
562
  503:
563
  description: 服务繁忙
564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  /analyze-semantic:
566
  post:
567
  tags:
 
562
  503:
563
  description: 服务繁忙
564
 
565
+ /tokenize:
566
+ post:
567
+ tags:
568
+ - all
569
+ summary: tokenize text
570
+ description: |
571
+ 对 context 用指定 model 的 tokenizer 分词,返回各 token 的字符 offset 与原文。
572
+ 不持有推理锁,不做前向 / 梯度计算,响应极快。
573
+ operationId: server.tokenize
574
+ parameters:
575
+ - in: body
576
+ name: tokenize_request
577
+ required: true
578
+ schema:
579
+ type: object
580
+ required:
581
+ - context
582
+ - model
583
+ properties:
584
+ context:
585
+ type: string
586
+ description: 待分词文本
587
+ model:
588
+ type: string
589
+ enum: [base, instruct]
590
+ description: base 使用主槽位 tokenizer,instruct 使用语义槽位 tokenizer
591
+ responses:
592
+ 200:
593
+ description: 分词结果
594
+ schema:
595
+ type: object
596
+ properties:
597
+ success:
598
+ type: boolean
599
+ spans:
600
+ type: array
601
+ items:
602
+ type: object
603
+ properties:
604
+ offset:
605
+ type: array
606
+ items:
607
+ type: integer
608
+ description: 字符偏移 [start, end]
609
+ raw:
610
+ type: string
611
+ description: token 原文
612
+ 400:
613
+ description: 缺少必要字段或 model 非法
614
+
615
  /analyze-semantic:
616
  post:
617
  tags: