DAG增加螺旋模式,支持teacher forcing
Browse files- backend/access_log.py +27 -15
- backend/api/openai_completions.py +7 -1
- backend/api/prediction_attribute.py +24 -3
- backend/api/tokenize.py +36 -0
- backend/completion_generator.py +5 -3
- backend/prediction_attributor.py +23 -7
- client/src/css/attribution.scss +0 -4
- client/src/css/gen_attribute.scss +89 -10
- client/src/gen_attribute.html +103 -59
- client/src/ts/attribution/genAttributeDagEdgeDisplay.ts +8 -0
- client/src/ts/attribution/genAttributeDagLinkSegment.ts +51 -0
- client/src/ts/attribution/genAttributeDagPreprocess.ts +10 -6
- client/src/ts/attribution/genAttributeDagView.ts +169 -30
- client/src/ts/attribution/genAttributeDagViewSpiralMode.ts +96 -0
- client/src/ts/attribution/genAttributeDagViewTextFlowMode.ts +1 -44
- client/src/ts/attribution/predictionAttributeClient.ts +36 -3
- client/src/ts/attribution/tokenGenAttributionRunner.ts +130 -7
- client/src/ts/chat/buildCompletionDisplayResult.ts +1 -1
- client/src/ts/gen_attribute.ts +292 -61
- client/src/ts/lang/translations.ts +9 -0
- client/src/ts/storage/genAttributeRunCache.ts +56 -7
- client/src/ts/utils/queryHistory.ts +2 -0
- client/src/ts/utils/topkChartUtils.ts +9 -1
- server.py +1 -0
- server.yaml +50 -0
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 |
-
|
|
|
|
| 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 =
|
| 154 |
-
t_preview =
|
| 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 =
|
| 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 |
-
|
| 217 |
-
|
| 218 |
-
|
| 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 |
-
|
| 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='{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, _, _ =
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
//
|
| 144 |
#results.gen-attr-results-surface.LMF
|
| 145 |
-
.gen-attr-dag-stack.gen-attr-dag-
|
| 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
|
| 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
|
| 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
|
| 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 =
|
| 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 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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」
|
| 160 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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(
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
}
|
| 328 |
|
| 329 |
/** 建边时调用:端点已带 {@link DagNodeAttrs.displayLabel} */
|
| 330 |
function buildLinkTitleText(
|
| 331 |
-
d: Pick<DagLink, '
|
| 332 |
src: DagNode,
|
| 333 |
tgt: DagNode
|
| 334 |
): string {
|
| 335 |
-
const s = d.
|
| 336 |
-
const
|
| 337 |
-
|
| 338 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/**
|
| 533 |
function syncStackLayoutDragUi(): void {
|
| 534 |
-
stackEl.classList.toggle('gen-attr-dag-
|
| 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 还会乘以
|
| 602 |
*/
|
| 603 |
function initialDagZoomK(): number {
|
| 604 |
return 1 / displayScale;
|
| 605 |
}
|
| 606 |
|
| 607 |
function defaultDagZoomK(): number {
|
| 608 |
-
return initialDagZoomK() *
|
| 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
|
|
|
|
| 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
|
| 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 |
-
// 逐条写 `
|
| 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
|
| 1017 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:统一请求
|
| 3 |
-
*
|
| 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
|
| 3 |
-
*
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = '
|
| 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 |
-
|
|
|
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
function applyDagLayoutModeUi(): void {
|
| 347 |
-
const
|
| 348 |
if (dagCompactnessGroup) {
|
| 349 |
-
|
|
|
|
| 350 |
}
|
| 351 |
if (dagMeasureWidthGroup) {
|
| 352 |
-
dagMeasureWidthGroup.hidden = !
|
| 353 |
}
|
| 354 |
if (dagLinearArcIntervalGroup) {
|
| 355 |
-
dagLinearArcIntervalGroup.hidden =
|
| 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.
|
|
|
|
|
|
|
| 578 |
if (!dagHandle.isBatching() && fitOnFirstStep) {
|
| 579 |
dagHandle.fitViewportToContent();
|
| 580 |
}
|
|
@@ -585,18 +642,29 @@ function pushDagFromPreprocess(
|
|
| 585 |
/** 下一步要 `pushDagFromPreprocess` 的步下标;与当前 DAG 前缀一致(暂停不重置) */
|
| 586 |
let dagPlaybackNextIndex = 0;
|
| 587 |
|
| 588 |
-
/**
|
| 589 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
* 点击播放时:读界面值并写回规范化结果,得到
|
| 636 |
* - `step`:固定间隔。
|
| 637 |
-
* - `total`:`totalS` 按**整段
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
replaceDemoUrlParam(null, DEFAULT_DEMO_URL_PARAM, 'gen_attribute');
|
| 1051 |
replaceContentUrlParam(
|
| 1052 |
-
buildCachedContentUrlParam(
|
| 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 |
-
|
| 1090 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
}
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
|
|
|
|
|
|
|
|
|
| 1096 |
}
|
| 1097 |
-
|
| 1098 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
| 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:
|
| 1383 |
maxTokens,
|
|
|
|
|
|
|
| 1384 |
onStep(step, stepIndex) {
|
| 1385 |
-
if (stepIndex === 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1406 |
.then(() => genCachedHistory.refreshList())
|
| 1407 |
-
.then(() => syncGenAttrContentUrl(
|
| 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 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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,
|
| 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(
|
| 77 |
-
return
|
| 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 ??
|
| 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:
|