Просмотр исходного кода

feat(api-v13): discussions 新增 assessment(译文评估)类型

- 新增 score 列(smallint,可空,0–100 的整数;译文质量是模糊评估,不要小数),severity 存评估方法
- 只挂句子、只有该句管理者能建(AI 凭 X-Agent-Grant 代写)、公开、可回复、没有开关、
  不收锚点、不发通知、不与其它类型互转;不带 type 的列表 / 角标 / summary 不含评估
- 一句只留一条:已有评估时就地覆盖(分数、方法、标题、理由、署名),返回 200,回复保留;
  锁句子行防并发重复
- 阅读页注入:句尾对勾按分数分档(≥80 high / ≥60 medium / low),在 issue 三角之后;
  同段有评估时,未评估的句子补灰色对勾标出句子边界;只在 html 格式输出

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 23 часов назад
Родитель
Сommit
ce38468e82

+ 21 - 11
api-v13/app/Http/Controllers/V3/DiscussionController.php

@@ -21,7 +21,7 @@ use Illuminate\Http\Request;
  * `GET/PUT/DELETE /v2/discussion/{discussion}`。v2 那份在 dashboard-v4 下线前原样保留。
  * 回复见 {@see DiscussionReplyController}(取代 view=answer 与带 parent 的 POST)。
  *
- * 底层仍是 `discussions` 表。批注会注入阅读页,规则与评论不同,见各方法说明。
+ * 底层仍是 `discussions` 表。批注与评估(assessment)会注入阅读页,规则与评论不同,见各方法说明。
  *
  * 写操作(新建、修改、删除,以及回复)支持 AI 代写:模型用模型 token 登录,并带请求头
  * `X-Agent-Grant`(人类经 `POST /v3/agent-grants` 签发)。此时 editor 记授权人、
@@ -43,16 +43,17 @@ class DiscussionController extends Controller
      * 只列话题(不含回复),删除了但还有回复的话题以墓碑出现(deleted_at 有值、内容为空)。
      * 未登录看不到不公开的话题(discussion);basic 角色在自己无权管理的对象上只看得到
      * 自己发起的不公开话题。各状态的数量与「能发起哪些类型」见 `GET /v3/discussions/summary`。
-     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式 type=note / commentary。
+     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注与评估要显式
+     * type=note / commentary / assessment。
      * 要一段经文(一章、一段段落区间)里的全部话题,用 `GET /v3/tipitaka-discussions/{channel}`。
      *
      * @unauthenticated
      *
      * @queryParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
      * @queryParam target_id string required 挂靠对象的 uuid
-     * @queryParam type string 类型,不传则只列评论线程。Enum: discussion,qa,help,issue,glossary,note,commentary
+     * @queryParam type string 类型,不传则只列评论线程。Enum: discussion,qa,help,issue,glossary,note,commentary,assessment
      * @queryParam status string 话题状态,不传则两种都要。Enum: active,close
-     * @queryParam severity string 严重程度,逗号分隔多选。Example: error,warning
+     * @queryParam severity string 严重程度(assessment 为评估方法),逗号分隔多选。Example: error,warning
      * @queryParam sort string 排序,前缀 - 表示倒序。Enum: created_at,-created_at,updated_at,-updated_at Default: -created_at
      * @queryParam page integer 页码。Default: 1
      * @queryParam per_page integer 每页数量,最大 200。Default: 15
@@ -67,7 +68,7 @@ class DiscussionController extends Controller
     }
 
     /**
-     * 发起话题或新建批注
+     * 发起话题、新建批注或评估
      *
      * discussion / issue 登录即可发;qa / help 只能发在文章上,且只有文章的编辑者能发。
      * severity 的取值随 type:issue 为 error / warning / suggestion,help 为 urgent / normal,
@@ -77,19 +78,26 @@ class DiscussionController extends Controller
      * 标题可省、正文必填,commentary 的正文必须是义注句子模板 `{{book-para-start-end}}`
      * (可多句并列);可带锚点 pos_* / quote_*;不发通知。
      *
+     * assessment(译文评估:用某种方法给这句译文打分)只能加在句子上,且只有该句所在 channel 的编辑者能加
+     * (AI 凭授权代写);score(0–100 的整数)必填,severity 必填、填评估方法(1–16 位字母数字 . _ -),
+     * 标题可省,理由写 content;不收锚点、不发通知。**一句只留一条**:已有评估时就地覆盖
+     * (分数、方法、理由、署名都换成这次的,回复保留),返回 200;没有才新建,返回 201。
+     *
      * glossary(外挂术语:不改译文,给锚点处的译文挂上术语)是评论线程,title 必填、填术语词头;
      * 与批注一样只能挂在句子上、只有该句所在 channel 的编辑者能发,对所有人公开可见。
      * 锚点只有 note / commentary / issue / glossary 收,其它类型传了 422。
      *
      * @responseStatus 201 创建成功
+     * @responseStatus 200 覆盖了该句已有的评估(只限 assessment)
      * @responseStatus 403 当前用户不能在这个对象上发起这种类型的话题
      *
      * @bodyParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
      * @bodyParam target_id string required 挂靠对象的 uuid
-     * @bodyParam type string 类型。Enum: discussion,qa,help,issue,glossary,note,commentary Default: discussion
-     * @bodyParam title string 标题,最长 256 字符;评论线程必填(glossary 填术语词头),批注可省
-     * @bodyParam content string 正文(markdown);批注必填
-     * @bodyParam severity string 严重程度。Enum: error,warning,suggestion,urgent,normal
+     * @bodyParam type string 类型。Enum: discussion,qa,help,issue,glossary,note,commentary,assessment Default: discussion
+     * @bodyParam title string 标题,最长 256 字符;评论线程必填(glossary 填术语词头),批注与评估可省
+     * @bodyParam content string 正文(markdown);批注必填,评估写打分理由
+     * @bodyParam severity string 严重程度:issue 为 error / warning / suggestion,help 为 urgent / normal;assessment 必填,填评估方法(1–16 位字母数字 . _ -)。Example: error
+     * @bodyParam score integer 评估分数,0–100 的整数(只限且必填于 assessment)。Example: 85
      * @bodyParam pos_start integer 锚点在句子文本中的起始偏移(只限 note / commentary / issue / glossary)
      * @bodyParam pos_end integer 锚点的结束偏移,批注插在这里(只限 note / commentary / issue / glossary)
      * @bodyParam quote_exact string 锚定的原文片段(只限 note / commentary / issue / glossary)
@@ -137,6 +145,7 @@ class DiscussionController extends Controller
      * 正文不能清空,可改锚点。
      * 改了内容(标题、正文、锚点)时 co_editor 跟着这次写入走:人亲自改则清空,AI 代改则换成该模型;
      * 只改 status / type / severity 不动 co_editor。
+     * 评估:可改 score、severity(评估方法,不能清空)、标题、正文;没有 status,不能与其它类型互转。
      *
      * @responseStatus 403 没有修改权限
      *
@@ -145,8 +154,9 @@ class DiscussionController extends Controller
      * @bodyParam title string 标题(只限话题)
      * @bodyParam content string 正文(markdown)
      * @bodyParam status string 状态(只限话题)。Enum: active,close
-     * @bodyParam type string 类型(只限话题;评论线程与批注不能互转)。Enum: discussion,qa,help,issue,glossary,note,commentary
-     * @bodyParam severity string 严重程度(只限话题),null 表示清空。Enum: error,warning,suggestion,urgent,normal
+     * @bodyParam type string 类型(只限话题;评论线程与批注不能互转)。Enum: discussion,qa,help,issue,glossary,note,commentary,assessment
+     * @bodyParam severity string 严重程度(只限话题),null 表示清空;assessment 为评估方法,不能清空。Example: warning
+     * @bodyParam score integer 评估分数,0–100 的整数(只限 assessment)。Example: 85
      * @bodyParam pos_start integer 锚点起始偏移(只限 note / commentary / issue / glossary)
      * @bodyParam pos_end integer 锚点结束偏移(只限 note / commentary / issue / glossary)
      * @bodyParam quote_exact string 锚定的原文片段(只限 note / commentary / issue / glossary)

+ 1 - 1
api-v13/app/Http/Controllers/V3/DiscussionSummaryController.php

@@ -30,7 +30,7 @@ class DiscussionSummaryController extends Controller
      *
      * @queryParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
      * @queryParam target_id string required 挂靠对象的 uuid
-     * @queryParam type string 只数这种类型,不传则只数评论线程。Enum: discussion,qa,help,issue,glossary,note,commentary
+     * @queryParam type string 只数这种类型,不传则只数评论线程。Enum: discussion,qa,help,issue,glossary,note,commentary,assessment
      */
     public function __invoke(SummaryDiscussionRequest $request)
     {

+ 3 - 3
api-v13/app/Http/Controllers/V3/TipitakaDiscussionController.php

@@ -28,8 +28,8 @@ class TipitakaDiscussionController extends Controller
      *
      * 只列挂在该 channel 句子上的话题(不含回复),按经文顺序(书、段、句)排列,同一句里按时间;
      * 每条带 `sentence`(所在句子的坐标)。删除了但还有回复的话题以墓碑出现。
-     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式
-     * type=note / commentary。可见性同 `GET /v3/discussions`:未登录看不到 discussion;
+     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注与评估要显式
+     * type=note / commentary / assessment。可见性同 `GET /v3/discussions`:未登录看不到 discussion;
      * basic 角色在自己不能编辑的 channel 上只看得到自己发起的 discussion。
      * channel 不存在或范围内没有讨论都返回空列表;chapter 不存在 404。
      *
@@ -45,7 +45,7 @@ class TipitakaDiscussionController extends Controller
      * @queryParam to integer 结束段落号(含),缺省同 para。Example: 896
      * @queryParam type string 类型,逗号分隔多选;不传则只列评论线程。Example: issue,glossary
      * @queryParam status string 话题状态,不传则两种都要。Enum: active,close
-     * @queryParam severity string 严重程度,逗号分隔多选。Example: error,warning
+     * @queryParam severity string 严重程度(assessment 为评估方法),逗号分隔多选。Example: error,warning
      * @queryParam page integer 页码。Default: 1
      * @queryParam per_page integer 每页数量,最大 200。Default: 15
      */

+ 17 - 4
api-v13/app/Http/Requests/V3/StoreDiscussionRequest.php

@@ -15,6 +15,9 @@ use Illuminate\Validation\Rule;
  * 批注(note / commentary)与评论线程的规则不同:只能挂在句子上、标题可省、正文必填,
  * commentary 的正文必须是义注句子模板。glossary 也只能挂在句子上。
  * 锚点(pos_* / quote_*)只有 {@see Discussion::ANCHORED_TYPES} 收。
+ *
+ * assessment(译文评估):score(0–100 的整数)与 severity(评估方法)必填,标题可省,理由写 content;
+ * 其它类型传 score 就 422。
  */
 class StoreDiscussionRequest extends FormRequest
 {
@@ -32,6 +35,7 @@ class StoreDiscussionRequest extends FormRequest
     {
         $type = $this->input('type', 'discussion');
         $severities = Discussion::SEVERITIES[$type] ?? [];
+        $isAssessment = $type === Discussion::TYPE_ASSESSMENT;
         $isAnnotation = in_array($type, Discussion::INJECTED_TYPES, true);
         $sentenceOnly = in_array($type, Discussion::SENTENCE_ONLY_TYPES, true);
         // 不收锚点的类型传了 422,免得静默丢掉
@@ -43,7 +47,7 @@ class StoreDiscussionRequest extends FormRequest
             'target_type' => ['required', 'string', Rule::in($sentenceOnly ? ['sentence'] : Discussion::TARGET_TYPES)],
             'target_id' => ['required', 'uuid'],
             'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
-            'title' => $isAnnotation
+            'title' => $isAnnotation || $isAssessment
                 ? ['sometimes', 'nullable', 'string', 'max:256']
                 : ['required', 'string', 'max:256'],
             'content' => match ($type) {
@@ -51,9 +55,14 @@ class StoreDiscussionRequest extends FormRequest
                 Discussion::TYPE_NOTE => ['required', 'string', 'max:20000'],
                 default => ['sometimes', 'nullable', 'string', 'max:20000'],
             },
-            'severity' => $severities === []
-                ? ['prohibited']
-                : ['sometimes', 'nullable', 'string', Rule::in($severities)],
+            'severity' => match (true) {
+                $isAssessment => ['required', 'string', 'regex:'.Discussion::ASSESSMENT_METHOD_PATTERN],
+                $severities === [] => ['prohibited'],
+                default => ['sometimes', 'nullable', 'string', Rule::in($severities)],
+            },
+            'score' => $isAssessment
+                ? ['required', 'integer', 'between:0,100']
+                : ['prohibited'],
             'pos_start' => $anchor(['integer', 'min:0']),
             'pos_end' => $anchor(['integer', 'min:0', 'gte:pos_start']),
             'quote_exact' => $anchor(['string', 'max:1024']),
@@ -72,11 +81,15 @@ class StoreDiscussionRequest extends FormRequest
         $messages = [
             'content.regex' => __('messages.discussion_commentary_format'),
             'severity.prohibited' => __('messages.discussion_severity_not_allowed'),
+            'severity.regex' => __('messages.discussion_assessment_method'),
+            'score.prohibited' => __('messages.discussion_score_not_allowed'),
         ];
         if (in_array($this->input('type'), Discussion::INJECTED_TYPES, true)) {
             $messages['target_type.in'] = __('messages.discussion_annotation_target');
         } elseif ($this->input('type') === Discussion::TYPE_GLOSSARY) {
             $messages['target_type.in'] = __('messages.discussion_glossary_target');
+        } elseif ($this->input('type') === Discussion::TYPE_ASSESSMENT) {
+            $messages['target_type.in'] = __('messages.discussion_assessment_target');
         }
         foreach (self::ANCHOR_FIELDS as $field) {
             $messages["{$field}.prohibited"] = __('messages.discussion_anchor_thread');

+ 2 - 1
api-v13/app/Http/Requests/V3/UpdateDiscussionRequest.php

@@ -12,7 +12,7 @@ use Illuminate\Validation\Rule;
  * title / status / type / severity 只对话题有效,回复传了 422——这一条与
  * severity 是否匹配最终 type,都要看库里那条记录,在 Service 里判。
  * 批注相关的规则(锚点只有可锚定的类型收、commentary 正文格式、不能与评论线程互转、没有 status)
- * 同样取决于那条记录的类型,也在 Service 里判。
+ * 同样取决于那条记录的类型,也在 Service 里判。score 只有 assessment 收,也在 Service 里判。
  */
 class UpdateDiscussionRequest extends FormRequest
 {
@@ -32,6 +32,7 @@ class UpdateDiscussionRequest extends FormRequest
             'status' => ['sometimes', 'string', Rule::in(Discussion::STATUSES)],
             'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
             'severity' => ['sometimes', 'nullable', 'string'],
+            'score' => ['sometimes', 'integer', 'between:0,100'],
             'pos_start' => ['sometimes', 'nullable', 'integer', 'min:0'],
             'pos_end' => ['sometimes', 'nullable', 'integer', 'min:0'],
             'quote_exact' => ['sometimes', 'nullable', 'string', 'max:1024'],

+ 4 - 1
api-v13/app/Http/Resources/V3/DiscussionResource.php

@@ -8,7 +8,8 @@ use App\Services\V3\DiscussionService;
 use Illuminate\Http\Request;
 
 /**
- * 一条评论:话题(parent_id 为 null)或回复;或一条批注(note / commentary)。
+ * 一条评论:话题(parent_id 为 null)或回复;或一条批注(note / commentary);或一条译文评估
+ * (assessment:score 是 0–100 的分数,severity 是评估方法,content 是理由)。
  *
  * 批注与 issue / glossary 可带锚点(pos_start / pos_end / quote_*),指明它挂在句子文本的
  * 哪一段;其它评论线程的这几个字段为 null。glossary 的 title 是术语词头。
@@ -43,6 +44,7 @@ class DiscussionResource extends BaseResource
      *     parent_id: string|null,
      *     type: string,
      *     severity: string|null,
+     *     score: int|null,
      *     status: string,
      *     title: string|null,
      *     content: string|null,
@@ -76,6 +78,7 @@ class DiscussionResource extends BaseResource
             'parent_id' => $this->parent,
             'type' => $this->type,
             'severity' => $this->severity,
+            'score' => $this->score,
             'status' => $this->status,
             'title' => $tombstone ? null : $this->title,
             'content' => $tombstone ? null : $this->content,

+ 27 - 7
api-v13/app/Models/Discussion.php

@@ -36,12 +36,14 @@ class Discussion extends Model
         'id' => 'string',
         'pos_start' => 'integer',
         'pos_end' => 'integer',
+        'score' => 'integer',
     ];
 
     // 批量填充
     protected $fillable = [
         'res_id',
         'res_type',
+        'score',
         'type',
         'tpl_id',
         'title',
@@ -77,6 +79,20 @@ class Discussion extends Model
      */
     public const TYPE_GLOSSARY = 'glossary';
 
+    /**
+     * 译文评估:用某种方法给一句译文打分。score 是归一到 0–100 的整数分数,severity 存评估方法
+     * ({@see self::ASSESSMENT_METHOD_PATTERN}),content 写理由,title 可省。
+     * 只能挂在句子上、只有该句的管理者能建(AI 凭授权代写);一句只留一条,重新评估覆盖旧的。
+     * 没有开关状态、不能与其它类型互转、不带锚点;注入阅读页,在句尾显示按分数着色的对勾。
+     */
+    public const TYPE_ASSESSMENT = 'assessment';
+
+    /** 评估方法(存在 severity 列,varchar(16)):字母数字开头,可含 . _ - */
+    public const ASSESSMENT_METHOD_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/';
+
+    /** 没有开关状态的类型:批注 + 评估 */
+    public const STATELESS_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_ASSESSMENT];
+
     /** 评论线程:话题 + 一层回复,有开关状态 */
     public const THREAD_TYPES = ['discussion', 'qa', 'help', 'issue', self::TYPE_GLOSSARY];
 
@@ -85,20 +101,24 @@ class Discussion extends Model
 
     /**
      * 会被注入阅读页的全部类型({@see PaliContentService::injectAnnotationNotes()}):
-     * 批注插边注,glossary 换成术语,issue 高亮。issue / glossary 只注入未关闭的。
+     * 批注插边注,glossary 换成术语,issue 高亮,assessment 句尾对勾。issue / glossary 只注入未关闭的。
      */
-    public const READING_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
+    public const READING_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY, self::TYPE_ASSESSMENT];
 
-    /** 只能挂在句子上的类型:批注 + glossary */
-    public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY];
+    /** 只能挂在句子上的类型:批注 + glossary + assessment */
+    public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY, self::TYPE_ASSESSMENT];
 
     /**
      * `/v3/discussions` 管的全部类型:评论线程({@see self::THREAD_TYPES})+ 批注
-     * ({@see self::INJECTED_TYPES})。批注锚定在句子的某个位置、注入阅读页,没有回复和开关。
+     * ({@see self::INJECTED_TYPES})+ 评估({@see self::TYPE_ASSESSMENT})。
+     * 批注与评估都注入阅读页,可以回复,没有开关。
      */
-    public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'glossary', 'note', 'commentary'];
+    public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'glossary', 'note', 'commentary', 'assessment'];
 
-    /** 各类型允许的 severity 取值;不在表里的类型不允许设 severity */
+    /**
+     * 各类型允许的 severity 取值;不在表里的类型不允许设 severity。
+     * assessment 例外:severity 存评估方法,按 {@see self::ASSESSMENT_METHOD_PATTERN} 校验,必填。
+     */
     public const SEVERITIES = [
         'issue' => ['error', 'warning', 'suggestion'],
         'help' => ['urgent', 'normal'],

+ 48 - 3
api-v13/app/Services/AnnotationLayout.php

@@ -12,7 +12,8 @@ namespace App\Services;
  * - glossary:区间 [start, end) 的原文换成 `[[title#原文]]`,MdRender 渲染成术语;
  * - issue:区间外包一层 `<span class="anno-issue …" data-discussion-ids="…">`(高亮,点击看内容);
  *   没有位置的 issue 合成一个三角叹号图标,放在句尾;
- * - note / commentary:在插入点插入调用方预先渲染好的 `{{note|…}}` 模板。
+ * - note / commentary:在插入点插入调用方预先渲染好的 `{{note|…}}` 模板;
+ * - assessment:句子最末尾一个对勾图标(在 issue 的三角之后),颜色按分数分档({@see self::assessmentLevel()})。
  *
  * 交叠的处理(核心约束:任何时候都不能切断一个标记):
  * - 受保护区间:content 里本来就有的标记(`[[…]]`、`{{…}}`、`` `…` ``、`**…**`、HTML 标签),
@@ -32,6 +33,9 @@ class AnnotationLayout
     /** 严重程度由轻到重;合并时取最重的,没有 severity 的最轻 */
     private const SEVERITY_RANK = ['suggestion' => 1, 'warning' => 2, 'error' => 3];
 
+    /** 评估分档的下限(含):由高到低,都不够就是 low */
+    private const ASSESSMENT_LEVELS = ['high' => 80, 'medium' => 60];
+
     /** glossary 的原文或词头里不能有这些字符,否则 `[[title#原文]]` 会被拆坏 */
     private const GLOSSARY_FORBIDDEN = '/[\[\]{}|#\r\n]/u';
 
@@ -99,13 +103,14 @@ class AnnotationLayout
      * @param  list<array{id: string, start: int, end: int, title: string}>  $glossaries  已定位的术语
      * @param  list<array{id: string, start: int|null, end: int|null, severity: string|null}>  $issues  start/end 为 null 表示没有位置
      * @param  list<array{id: string, at: int, order: array<int, int>, html: string}>  $notes  at 是插入点,order 是同一插入点上的先后
-     * @param  bool  $withIssues  是否输出 issue 的高亮与图标(只有 html 格式需要)
+     * @param  bool  $withIssues  是否输出 issue 的高亮与图标、评估的对勾(只有 html 格式需要)
+     * @param  array{id: string, score: int}|null  $assessment  这句的评估,没有为 null
      * @return array{content: string, notes: list<string>, glossaries: list<string>, issues: list<array{ids: list<string>, severity: string|null, start: int|null, end: int|null}>}
      *
      * 返回值里 notes 是 note id 的阅读顺序,glossaries 是实际替换了的术语 id,
      * issues 是合并后的高亮(没有位置的合成一条,start / end 为 null)。
      */
-    public static function apply(string $content, array $glossaries, array $issues, array $notes, bool $withIssues = true): array
+    public static function apply(string $content, array $glossaries, array $issues, array $notes, bool $withIssues = true, ?array $assessment = null): array
     {
         $len = mb_strlen($content, 'UTF-8');
         $protected = self::protectedRanges($content);
@@ -226,6 +231,9 @@ class AnnotationLayout
                 self::maxSeverity(array_column($unanchored, 'severity')),
             );
         }
+        if ($withIssues && $assessment !== null) {
+            $out .= self::assessmentMark($assessment['id'], $assessment['score']);
+        }
 
         $report = array_map(fn ($s) => [
             'ids' => $s['ids'], 'severity' => $s['severity'], 'start' => $s['start'], 'end' => $s['end'],
@@ -342,6 +350,43 @@ class AnnotationLayout
             .' data-severity="'.e($severity ?? '').'">';
     }
 
+    /**
+     * 评估分数的档位:high(≥80)/ medium(≥60)/ low。阅读器按档位着色(绿 / 橙 / 红)。
+     */
+    public static function assessmentLevel(int $score): string
+    {
+        foreach (self::ASSESSMENT_LEVELS as $level => $min) {
+            if ($score >= $min) {
+                return $level;
+            }
+        }
+
+        return 'low';
+    }
+
+    /**
+     * 没有评估的句子:灰色对勾,不能点(没有 discussion id)。只在同段有别的句子评估过时由
+     * {@see PaliContentService::renderReadSentences()} 补上,标出句子的边界。
+     */
+    public static function unassessedMark(): string
+    {
+        return '<span class="anno-assessment-mark anno-assessment-none" data-level="none" aria-hidden="true">'
+            .'<i class="ti ti-circle-check"></i></span>';
+    }
+
+    /**
+     * 评估:句尾一个对勾图标,颜色按分数档位,点击看分数与理由。
+     */
+    private static function assessmentMark(string $id, int $score): string
+    {
+        $level = self::assessmentLevel($score);
+
+        return '<span class="anno-assessment-mark anno-assessment-'.$level.'"'
+            .' data-discussion-ids="'.e($id).'"'
+            .' data-score="'.$score.'" data-level="'.$level.'" role="button" tabindex="0">'
+            .'<i class="ti ti-circle-check"></i></span>';
+    }
+
     /**
      * 没有位置的 issue:句尾一个三角叹号图标,颜色取最重的 severity。
      *

+ 33 - 7
api-v13/app/Services/PaliContentService.php

@@ -752,22 +752,25 @@ class PaliContentService
      * - `glossary`(外挂术语):锚点处的原文换成 `[[title#原文]]`,渲染成术语(title 是词头);
      * - `issue`:锚点处的原文包一层高亮 span,带 discussion id,阅读器点击看内容;
      *   没有位置的合成一个三角叹号图标放在句尾。只在 html 格式输出。
-     * issue / glossary 只注入未关闭的;批注没有开关状态。
+     * - `assessment`(译文评估):句尾一个对勾图标,颜色按分数分档,带 discussion id,
+     *   阅读器点击看分数与理由。一句只有一条(有并发残留时取最近更新的)。只在 html 格式输出。
+     *   同段里其它没有评估的句子由 {@see self::renderReadSentences()} 补灰色对勾。
+     * issue / glossary 只注入未关闭的;批注与评估没有开关状态。
      *
      * 位置按 pos_* 定位,与 quote_exact 对不上(正文改过)时按摘录重新定位。
      *
-     * @return array{content: string, notes: list<array{fnId: string, noteHtml: string, citeHtml: string}>}
+     * @return array{content: string, notes: list<array{fnId: string, noteHtml: string, citeHtml: string}>, assessed: bool}
      */
     protected function injectAnnotationNotes(Sentence $row, string $channelType, string $format = 'html'): array
     {
         $content = (string) $row->content;
-        $result = ['content' => $content, 'notes' => []];
+        $result = ['content' => $content, 'notes' => [], 'assessed' => false];
         $records = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
             ->whereIn('type', Discussion::READING_TYPES)
             // 只注入话题本身;回复(类型跟随话题)不进阅读页
             ->whereNull('parent')
-            ->where(fn ($q) => $q->whereIn('type', Discussion::INJECTED_TYPES)->orWhere('status', 'active'))
+            ->where(fn ($q) => $q->whereIn('type', Discussion::STATELESS_TYPES)->orWhere('status', 'active'))
             ->orderBy('created_at')
             ->get();
 
@@ -783,7 +786,15 @@ class PaliContentService
         $issues = [];
         $notes = [];
         $footnotes = [];
+        $assessment = null;
         foreach ($records as $record) {
+            if ($record->type === Discussion::TYPE_ASSESSMENT) {
+                if ($record->score !== null && ($assessment === null || $record->updated_at >= $assessment['updated_at'])) {
+                    $assessment = ['id' => $record->id, 'score' => (int) $record->score, 'updated_at' => $record->updated_at];
+                }
+
+                continue;
+            }
             if ($record->type === Discussion::TYPE_GLOSSARY) {
                 $range = $locate($record);
                 if ($range !== null) {
@@ -816,9 +827,17 @@ class PaliContentService
             ];
         }
 
-        $layout = AnnotationLayout::apply($content, $glossaries, $issues, $notes, $format === 'html');
+        $layout = AnnotationLayout::apply(
+            $content,
+            $glossaries,
+            $issues,
+            $notes,
+            $format === 'html',
+            $assessment === null ? null : ['id' => $assessment['id'], 'score' => $assessment['score']],
+        );
         $result['content'] = $layout['content'];
         $result['notes'] = array_map(fn ($id) => $footnotes[$id], $layout['notes']);
+        $result['assessed'] = $assessment !== null;
 
         Log::info('reading-annotations: inject', [
             'sid' => "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}",
@@ -950,9 +969,16 @@ class PaliContentService
             ->get();
 
         $allNotes = [];
-        foreach ($records as $row) {
-            $injected = $this->injectAnnotationNotes($row, $channelType, $format);
+        $injections = $records->map(fn (Sentence $row) => $this->injectAnnotationNotes($row, $channelType, $format));
+        // 段里有句子评估过,其余没评估的句子在句尾补灰色对勾:不然读者会把它与下一句的
+        // 对勾连起来,误以为是一句。整段都没评估就都不补(段落本身有分隔)。
+        $markUnassessed = $format === 'html' && $injections->contains('assessed', true);
+        foreach ($records as $i => $row) {
+            $injected = $injections[$i];
             $content = $injected['content'];
+            if ($markUnassessed && ! $injected['assessed'] && trim($content) !== '') {
+                $content .= AnnotationLayout::unassessedMark();
+            }
             $allNotes = array_merge($allNotes, $injected['notes']);
             $html = MdRender::render(
                 $content,

+ 74 - 13
api-v13/app/Services/V3/DiscussionService.php

@@ -42,6 +42,10 @@ use Illuminate\Validation\ValidationException;
  * glossary(外挂术语)是评论线程(有回复和开关),但像批注一样只能挂在句子上、只有该句的管理者能发起,
  * 对所有人公开可见。锚点除批注外,issue 与 glossary 也收({@see Discussion::ANCHORED_TYPES})。
  *
+ * assessment(译文评估)像批注一样:只挂句子、只有该句的管理者能建(AI 凭授权代写)、公开、
+ * 可回复、没有开关、不发通知、列表不带 type 时不列。一句只留一条,再评估就覆盖
+ * ({@see self::saveAssessment()});分数在 score,评估方法在 severity。
+ *
  * 写入方(`$actor`)由 {@see AgentGrantService::actor()} 给出:人类直接写,或 AI 模型凭人类
  * 授权代写。代写时 editor 是授权人、co_editor 是模型,权限一律按 editor 判,
  * 而且只能写授权覆盖的 channel / book 里的东西。
@@ -332,10 +336,13 @@ class DiscussionService
     }
 
     /**
-     * 发起话题,或新建一条批注(批注不发通知)。
+     * 发起话题,或新建一条批注 / 评估(批注与评估不发通知)。
+     *
+     * 评估(assessment)一句只留一条:该句已有评估就就地覆盖(分数、方法、理由、标题、署名),
+     * 回复保留,返回 200;没有才新建,返回 201。
      *
      * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
-     * @param  array{target_type: string, target_id: string, type?: string, title?: string|null, content?: string|null, severity?: string|null, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null, tpl_id?: string|null, notify?: bool}  $data
+     * @param  array{target_type: string, target_id: string, type?: string, title?: string|null, content?: string|null, severity?: string|null, score?: int, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null, tpl_id?: string|null, notify?: bool}  $data
      */
     public function createTopic(array $actor, array $data): Discussion
     {
@@ -349,6 +356,9 @@ class DiscussionService
         if (! in_array($type, $this->creatableTypes($userUid, $data['target_type'], $data['target_id']), true)) {
             abort(403, __('messages.discussion_forbidden'));
         }
+        if ($type === Discussion::TYPE_ASSESSMENT) {
+            return $this->fresh($this->saveAssessment($actor, $data), $userUid);
+        }
 
         $topic = new Discussion;
         $topic->res_type = $data['target_type'];
@@ -371,6 +381,40 @@ class DiscussionService
         return $this->fresh($topic, $userUid);
     }
 
+    /**
+     * 新建或覆盖一句的评估。锁住句子行,免得并发评估同一句时各建一条。
+     * 覆盖时署名换成这次评估的人(及代写的模型),created_at 不变。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     * @param  array{target_id: string, title?: string|null, content?: string|null, severity?: string|null, score?: int, tpl_id?: string|null}  $data
+     */
+    private function saveAssessment(array $actor, array $data): Discussion
+    {
+        return DB::transaction(function () use ($actor, $data) {
+            Sentence::where('uid', $data['target_id'])->lockForUpdate()->first(['uid']);
+            $assessment = Discussion::where('res_type', 'sentence')
+                ->where('res_id', $data['target_id'])
+                ->where('type', Discussion::TYPE_ASSESSMENT)
+                ->whereNull('parent')
+                ->latest('updated_at')
+                ->first() ?? new Discussion;
+            $assessment->res_type = 'sentence';
+            $assessment->res_id = $data['target_id'];
+            $assessment->type = Discussion::TYPE_ASSESSMENT;
+            $assessment->title = $data['title'] ?? null;
+            $assessment->content = $data['content'] ?? null;
+            $assessment->content_type = 'markdown';
+            $assessment->severity = $data['severity'];
+            $assessment->score = (int) $data['score'];
+            $assessment->tpl_id = $data['tpl_id'] ?? null;
+            $assessment->editor_uid = $actor['editor'];
+            $assessment->co_editor_uid = $actor['co_editor'];
+            $assessment->save();
+
+            return $assessment;
+        });
+    }
+
     /**
      * 回复话题。话题必须没关闭;不能回复一条回复。
      *
@@ -426,12 +470,13 @@ class DiscussionService
      * 改成 glossary 须挂在句子上、且当前用户是该句的管理者(同新建)。
      * 批注:锚点可改,没有 status,type 只能在 note / commentary 之间改,
      * 正文不能清空,commentary 的正文必须是义注句子模板(按改后的 type 与正文判)。
+     * 评估:可改 score、severity(评估方法,不能清空)、标题、正文;没有 status,不能与其它类型互转。
      *
      * 改了内容(标题、正文、锚点)时 co_editor 改成这次写入的协作者:人亲自改则清空,
-     * AI 代改则换成该模型。只改 status / type / severity 不动 co_editor。
+     * AI 代改则换成该模型。只改 status / type / severity 不动 co_editor(评估的 score 算内容)。
      *
      * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
-     * @param  array{title?: string, content?: string|null, status?: string, type?: string, severity?: string|null, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null}  $data
+     * @param  array{title?: string, content?: string|null, status?: string, type?: string, severity?: string|null, score?: int, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null}  $data
      */
     public function update(Discussion $discussion, array $actor, array $data): Discussion
     {
@@ -444,7 +489,7 @@ class DiscussionService
             abort(403, __('messages.discussion_forbidden'));
         }
         $isTopic = $discussion->parent === null;
-        $topicOnly = array_intersect_key($data, array_flip(['title', 'status', 'type', 'severity']));
+        $topicOnly = array_intersect_key($data, array_flip(['title', 'status', 'type', 'severity', 'score']));
         if (! $isTopic && $topicOnly !== []) {
             throw ValidationException::withMessages(
                 array_fill_keys(array_keys($topicOnly), __('messages.discussion_topic_only'))
@@ -459,7 +504,13 @@ class DiscussionService
         if ($type === Discussion::TYPE_GLOSSARY && $discussion->type !== Discussion::TYPE_GLOSSARY) {
             $this->assertCanBecomeGlossary($discussion, $userUid);
         }
-        if (array_key_exists('severity', $data)) {
+        if ($type === Discussion::TYPE_ASSESSMENT) {
+            // 评估的 severity 是评估方法:不能清空,格式见 ASSESSMENT_METHOD_PATTERN
+            if (array_key_exists('severity', $data)
+                && ! preg_match(Discussion::ASSESSMENT_METHOD_PATTERN, (string) $data['severity'])) {
+                throw ValidationException::withMessages(['severity' => __('messages.discussion_assessment_method')]);
+            }
+        } elseif (array_key_exists('severity', $data)) {
             if ($data['severity'] !== null && ! in_array($data['severity'], Discussion::SEVERITIES[$type] ?? [], true)) {
                 throw ValidationException::withMessages(['severity' => __('messages.discussion_severity_not_allowed')]);
             }
@@ -469,7 +520,7 @@ class DiscussionService
 
         DB::transaction(function () use ($discussion, $data, $actor) {
             $typeChanged = array_key_exists('type', $data) && $data['type'] !== $discussion->type;
-            $authored = array_intersect_key($data, array_flip(['title', 'content', ...self::ANCHOR_FIELDS]));
+            $authored = array_intersect_key($data, array_flip(['title', 'content', 'score', ...self::ANCHOR_FIELDS]));
             $discussion->fill($authored);
             // 改了内容,co_editor 就跟着这次写的人走:人亲自改 → 清空,AI 代改 → 换成这个模型。
             // 只改状态 / 类型 / severity 是管理动作,不动内容的署名。
@@ -590,18 +641,28 @@ class DiscussionService
     }
 
     /**
-     * 批注与评论线程在 PATCH 上的差异,见 {@see self::update()}。
+     * 批注、评估与评论线程在 PATCH 上的差异,见 {@see self::update()}。
      *
      * @param  array<string, mixed>  $data
      */
     private function assertAnnotationUpdate(Discussion $discussion, array $data): void
     {
         $isAnnotation = $this->isAnnotation($discussion);
+        $isAssessment = $discussion->type === Discussion::TYPE_ASSESSMENT;
         $errors = [];
         if (array_key_exists('type', $data)
+            && ($data['type'] === Discussion::TYPE_ASSESSMENT) !== $isAssessment) {
+            $errors['type'] = __('messages.discussion_assessment_type');
+        } elseif (array_key_exists('type', $data)
             && in_array($data['type'], Discussion::INJECTED_TYPES, true) !== $isAnnotation) {
             $errors['type'] = __('messages.discussion_type_group');
         }
+        if (array_key_exists('score', $data) && ! $isAssessment) {
+            $errors['score'] = __('messages.discussion_score_not_allowed');
+        }
+        if ($isAssessment && array_key_exists('status', $data)) {
+            $errors['status'] = __('messages.discussion_assessment_no_status');
+        }
         if (! in_array($data['type'] ?? $discussion->type, Discussion::ANCHORED_TYPES, true)) {
             foreach (array_intersect(self::ANCHOR_FIELDS, array_keys($data)) as $field) {
                 $errors[$field] = __('messages.discussion_anchor_thread');
@@ -707,7 +768,7 @@ class DiscussionService
      * 当前用户能在这个对象上发起哪些类型的话题。
      *
      * 协作类(discussion / issue)登录即可;qa / help 沿用 v2 `can_create` 的口径:
-     * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)与 glossary 只能加在
+     * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)、glossary 与 assessment 只能加在
      * 句子上,而且只有该句的管理者能加(v2 登录即可,v3 收紧)。
      *
      * @return list<string>
@@ -721,9 +782,9 @@ class DiscussionService
         if ($targetType === 'article' && $this->canManageTarget($userUid, $targetType, $targetId)) {
             array_push($types, 'qa', 'help');
         }
-        // 批注注入阅读页、所有读者可见,只有该句的管理者能加
+        // 批注与评估注入阅读页、所有读者可见,只有该句的管理者能加
         if ($targetType === 'sentence' && $this->canManageTarget($userUid, $targetType, $targetId)) {
-            array_push($types, Discussion::TYPE_GLOSSARY, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY);
+            array_push($types, Discussion::TYPE_GLOSSARY, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY, Discussion::TYPE_ASSESSMENT);
         }
 
         return $types;
@@ -743,8 +804,8 @@ class DiscussionService
         $isAuthor = $discussion->editor_uid === $viewerUid;
         $canManage = $isAuthor || $this->canManageTarget($viewerUid, $discussion->res_type, $discussion->res_id);
         $isTopic = $discussion->parent === null;
-        if ($this->isAnnotation($discussion)) {
-            // 批注没有开关;回复与评论线程一样,登录即可
+        if (in_array($discussion->type, Discussion::STATELESS_TYPES, true)) {
+            // 批注与评估没有开关;回复与评论线程一样,登录即可
             return [
                 'update' => $canManage,
                 'delete' => $canManage,

+ 29 - 0
api-v13/database/migrations/2026_10_22_100000_add_score_in_discussions.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * 给 discussions 增加评估分数 score,只有 type=assessment(译文评估)有值。
+     * 统一归一到 0–100 的整数:译文质量是模糊评估,小数没有意义。评估方法记在 severity 里。
+     *
+     * nullable:存量数据与 dashboard-v4(v2 写入)都不会带这个值。
+     */
+    public function up(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->smallInteger('score')->nullable()->index()
+                ->comment('评估分数 0–100,只有 type=assessment 有值');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->dropColumn('score');
+        });
+    }
+};

+ 5 - 0
api-v13/resources/lang/en/messages.php

@@ -29,6 +29,11 @@ return [
     'discussion_commentary_format' => 'Commentary content must be sentence templates such as {{book-para-start-end}}.',
     'discussion_anchor_thread' => 'This discussion type cannot be anchored to a text position.',
     'discussion_glossary_target' => 'Attached glossary terms can only be added to sentences.',
+    'discussion_assessment_target' => 'Assessments can only be attached to sentences.',
+    'discussion_assessment_method' => 'The assessment method must be 1–16 letters, digits, dots, underscores or hyphens, starting with a letter or digit.',
+    'discussion_score_not_allowed' => 'Only assessments can have a score.',
+    'discussion_assessment_type' => 'An assessment cannot be converted to or from another type.',
+    'discussion_assessment_no_status' => 'Assessments cannot be closed or reopened.',
     'agent_grant_by_model' => 'Only a person can grant an AI model permission to write.',
     'agent_grant_model_not_found' => 'AI model not found.',
     'agent_grant_not_owner' => 'This AI model does not belong to you.',

+ 5 - 0
api-v13/resources/lang/zh-Hans/messages.php

@@ -29,6 +29,11 @@ return [
     'discussion_commentary_format' => '义注的内容必须是句子模板,如 {{book-para-start-end}}。',
     'discussion_anchor_thread' => '这种类型的讨论不能锚定到文本位置。',
     'discussion_glossary_target' => '外挂术语只能挂在句子上。',
+    'discussion_assessment_target' => '评估只能挂在句子上。',
+    'discussion_assessment_method' => '评估方法须为 1–16 个字母、数字、点、下划线或连字符,以字母或数字开头。',
+    'discussion_score_not_allowed' => '只有评估可以带分数。',
+    'discussion_assessment_type' => '评估不能与其它类型互相转换。',
+    'discussion_assessment_no_status' => '评估没有关闭 / 打开状态。',
     'agent_grant_by_model' => '只有人类能给 AI 模型授权写入。',
     'agent_grant_model_not_found' => 'AI 模型不存在。',
     'agent_grant_not_owner' => '这个 AI 模型不属于你。',