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

test(api-v13): 覆盖 assessment 的权限、覆盖式写入、校验、PATCH、AI 代写与阅读页注入

- 只有句子管理者能建、公开可读、不发通知、不带 type 的列表不列
- 再评估覆盖同一条(200、id 与 created_at 不变、回复保留)
- score / 方法 / 锚点 / 类型互转的校验
- 模型凭授权重评:editor 是授权人、co_editor 是模型
- 阅读页句尾对勾按分数分档、改分清缓存、纯文本不带标记;同段未评估句子补灰色对勾

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

+ 20 - 0
api-v13/tests/Feature/AgentGrantV3Test.php

@@ -100,6 +100,26 @@ it('records the grantor as editor and the model as co-editor', function () {
     ], authHeader($human))->assertCreated()->assertJsonPath('data.co_editor', null);
     ], authHeader($human))->assertCreated()->assertJsonPath('data.co_editor', null);
 });
 });
 
 
+it('lets a model assess a sentence on a grant, overwriting with itself as co-editor', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $payload = ['target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'assessment', 'severity' => 'llm-judge'];
+
+    $this->postJson('/api/v3/discussions', [...$payload, 'score' => 70], modelHeader($model))->assertForbidden();
+
+    // 人先评,模型凭授权重评:覆盖同一条,editor 是授权人、co_editor 是模型
+    $id = $this->postJson('/api/v3/discussions', [...$payload, 'score' => 70], authHeader($human))
+        ->assertCreated()->assertJsonPath('data.co_editor', null)->json('data.id');
+    $this->postJson('/api/v3/discussions', [...$payload, 'score' => 81, 'content' => '理由'], modelHeader($model, grantFor($human, $model, $channel)))
+        ->assertOk()
+        ->assertJsonPath('data.id', $id)
+        ->assertJsonPath('data.score', 81)
+        ->assertJsonPath('data.editor.id', $human)
+        ->assertJsonPath('data.co_editor.id', $model->uid);
+});
+
 it('rejects agent writes that are not covered by a valid grant', function () {
 it('rejects agent writes that are not covered by a valid grant', function () {
     $human = makeStudio('owner');
     $human = makeStudio('owner');
     $channel = makeChannel($human);
     $channel = makeChannel($human);

+ 102 - 2
api-v13/tests/Feature/DiscussionV3Test.php

@@ -205,7 +205,7 @@ it('summarises status counts and creatable types', function () {
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
         ->assertOk()
         ->assertOk()
         ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
         ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
-        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']);   // 句子的管理者能加批注
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary', 'assessment']);   // 句子的管理者能加批注
 
 
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
         ->assertOk()
         ->assertOk()
@@ -478,7 +478,7 @@ it('lets only the sentence manager create annotations, without notifying', funct
         'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
         'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
 
 
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
-        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']);
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary', 'assessment']);
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
     $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
         ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
         ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
 
 
@@ -550,6 +550,106 @@ it('still hides records that are neither threads nor annotations', function () {
     $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
     $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
 });
 });
 
 
+/* ------------------------------------------------------------------ 译文评估 */
+
+it('lets only the sentence manager assess a sentence, publicly and without notifying', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'assessment',
+        'severity' => 'mqm', 'score' => 87.5, 'content' => '术语一致,语序略生硬'];
+
+    $this->postJson('/api/v3/discussions', $payload)->assertUnauthorized();
+    $this->postJson('/api/v3/discussions', $payload, authHeader(makeStudio('stranger')))->assertForbidden();
+
+    Log::spy();
+    $id = $this->postJson('/api/v3/discussions', $payload, authHeader($owner))   // 没传 notify:评估本来就不发
+        ->assertCreated()
+        ->assertJsonPath('data.type', 'assessment')
+        ->assertJsonPath('data.severity', 'mqm')
+        ->assertJsonPath('data.score', 87.5)
+        ->assertJsonPath('data.title', null)
+        ->assertJsonPath('data.content', '术语一致,语序略生硬')
+        ->assertJsonPath('data.abilities', ['update' => true, 'delete' => true, 'close' => false, 'reply' => true])
+        ->json('data.id');
+    Log::shouldNotHaveReceived('debug', fn (string $message) => $message === 'mq publish');
+
+    // 公开:匿名看得到;不带 type 的列表不列评估
+    $this->getJson("/api/v3/discussions/{$id}")->assertOk()->assertJsonPath('data.score', 87.5);
+    $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
+    $this->getJson($base)->assertJsonCount(0, 'data');
+    $this->getJson("{$base}&type=assessment")->assertJsonCount(1, 'data');
+});
+
+it('overwrites the existing assessment of a sentence instead of adding another', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $post = fn (array $data) => $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $target, 'type' => 'assessment', ...$data,
+    ], authHeader($owner));
+
+    $first = $post(['severity' => 'likert', 'score' => 40, 'title' => '初评', 'content' => '漏译'])
+        ->assertCreated()->json('data');
+    $this->postJson("/api/v3/discussions/{$first['id']}/replies", ['content' => '同意', 'notify' => false], authHeader(makeStudio('reader')))
+        ->assertCreated();
+
+    // 重新评估:同一条被覆盖(没传的标题、理由清空),回复保留,返回 200
+    $second = $post(['severity' => 'mqm', 'score' => 92])
+        ->assertOk()
+        ->assertJsonPath('data.id', $first['id'])
+        ->assertJsonPath('data.severity', 'mqm')
+        ->assertJsonPath('data.score', 92)
+        ->assertJsonPath('data.title', null)
+        ->assertJsonPath('data.content', null)
+        ->assertJsonPath('data.reply_count', 1)
+        ->json('data');
+
+    expect($second['created_at'])->toBe($first['created_at'])
+        ->and(Discussion::where('res_id', $target)->whereNull('parent')->where('type', 'assessment')->count())->toBe(1);
+});
+
+it('validates assessment input', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $post = fn (array $data) => $this->postJson('/api/v3/discussions',
+        ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'assessment', 'severity' => 'mqm', 'score' => 80, ...$data],
+        authHeader($owner));
+
+    $post(['score' => null])->assertStatus(422)->assertJsonValidationErrors('score');
+    $post(['score' => 101])->assertStatus(422)->assertJsonValidationErrors('score');
+    $post(['score' => -1])->assertStatus(422)->assertJsonValidationErrors('score');
+    $post(['severity' => null])->assertStatus(422)->assertJsonValidationErrors('severity');
+    $post(['severity' => 'has space'])->assertStatus(422)->assertJsonValidationErrors('severity');
+    $post(['severity' => str_repeat('a', 17)])->assertStatus(422)->assertJsonValidationErrors('severity');
+    $post(['target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
+    $post(['pos_start' => 0, 'pos_end' => 2])->assertStatus(422)->assertJsonValidationErrors(['pos_start', 'pos_end']);
+    // 其它类型不收分数
+    $this->postJson('/api/v3/discussions', ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'issue',
+        'title' => 't', 'score' => 50, 'notify' => false], authHeader($owner))
+        ->assertStatus(422)->assertJsonValidationErrors('score');
+});
+
+it('updates an assessment score and method, and keeps it apart from other types', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $assessment = makeDiscussion(['res_id' => $target, 'type' => 'assessment', 'title' => null,
+        'severity' => 'mqm', 'score' => 50]);
+    $topic = makeDiscussion(['res_id' => $target, 'type' => 'issue']);
+    $patch = fn (Discussion $d, array $data) => $this->patchJson("/api/v3/discussions/{$d->id}", $data, authHeader($owner));
+
+    $patch($assessment, ['score' => 120])->assertStatus(422)->assertJsonValidationErrors('score');
+    $patch($assessment, ['severity' => null])->assertStatus(422)->assertJsonValidationErrors('severity');
+    $patch($assessment, ['status' => 'close'])->assertStatus(422)->assertJsonValidationErrors('status');
+    $patch($assessment, ['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('type');
+    $patch($topic, ['type' => 'assessment'])->assertStatus(422)->assertJsonValidationErrors('type');
+    $patch($topic, ['score' => 10])->assertStatus(422)->assertJsonValidationErrors('score');
+
+    $patch($assessment, ['score' => 66.25, 'severity' => 'llm-judge', 'content' => '复核'])
+        ->assertOk()
+        ->assertJsonPath('data.score', 66.25)
+        ->assertJsonPath('data.severity', 'llm-judge')
+        ->assertJsonPath('data.content', '复核');
+});
+
 /* ------------------------------------------------------------------ 外挂术语与锚点 */
 /* ------------------------------------------------------------------ 外挂术语与锚点 */
 
 
 it('lets only the sentence manager create a glossary topic, with the headword as title and an anchor', function () {
 it('lets only the sentence manager create a glossary topic, with the headword as title and an anchor', function () {

+ 51 - 0
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -166,3 +166,54 @@ it('leaves issue markup out of plain text output but keeps the glossary term', f
 
 
     expect($text)->toBe('first sentence');
     expect($text)->toBe('first sentence');
 });
 });
+
+/* ------------------------------------------------------------------ 评估 */
+
+it('marks an assessed sentence with a check coloured by score, after any issue mark', function (float $score, string $level) {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    makeAnchored($sentence->uid, 'issue', ['severity' => 'warning']);
+    $assessment = makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => $score]);
+
+    $display = readFirstParagraph($channel);
+
+    expect($display)
+        ->toContain('<span class="anno-assessment-mark anno-assessment-'.$level.'" data-discussion-ids="'.$assessment->id.'"')
+        ->toContain('data-level="'.$level.'"')
+        ->toContain('<i class="ti ti-circle-check"></i>')
+        ->and(mb_strpos($display, 'anno-issue-mark'))->toBeLessThan(mb_strpos($display, 'anno-assessment-mark'));
+})->with([
+    'high' => [80.0, 'high'],
+    'medium' => [79.99, 'medium'],
+    'low' => [12.5, 'low'],
+]);
+
+it('drops the cache when an assessment is rescored, and leaves it out of plain text', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    $assessment = makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => 30]);
+
+    expect(readFirstParagraph($channel))->toContain('data-score="30"');
+
+    $assessment->forceFill(['score' => 95.5])->save();
+    expect(readFirstParagraph($channel))->toContain('data-score="95.5"')->toContain('anno-assessment-high');
+
+    $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
+    expect($text)->toBe('first sentence');
+});
+
+it('marks unassessed sentences grey only in a paragraph that has an assessed sentence', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();   // 9101-1,第一句
+    makeSentence($channel, 9101, 1, 2, 'second sentence');
+    makeSentence($channel, 9101, 2, 1, 'other paragraph');
+    makeAnchored($sentence->uid, 'assessment', ['title' => null, 'severity' => 'mqm', 'score' => 90]);
+
+    $display = readFirstParagraph($channel);
+    expect(substr_count($display, 'anno-assessment-high'))->toBe(1)
+        ->and(substr_count($display, 'anno-assessment-none'))->toBe(1)
+        ->and($display)->toContain('second sentence<span class="anno-assessment-mark anno-assessment-none"');
+
+    $other = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=2")->assertOk()->json('data.0.display');
+    expect($other)->not->toContain('anno-assessment');
+
+    $text = app(PaliContentService::class)->readParagraph(9101, 1, $channel, 'text')['display'];
+    expect($text)->not->toContain('anno-assessment');
+});