Skip to content

Commit 0f9e863

Browse files
sudoghutclaude
andauthored
修正 AI 任官自動填充對「同知」的官名匹配 (#998)
- PostingAutofillService::fuzzyMatchOffice 新增 admin_type 參數 - Step 1 朝代專屬精確匹配落空後,依地址 admin_type 消歧: 府 → 同知某府軍府事、州/縣 → 同知某州軍州事 - 放在 Step 1 之後可保留明清等朝代專屬「同知」記錄, 避免覆蓋朝代專屬精確匹配 - 新增 tests/Feature/PostingAutofillOfficeMatchTest 覆蓋宋/明/清 + 府/州/縣 - 已執行 ./vendor/bin/phpunit --filter PostingAutofillOfficeMatchTest Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad3a4b0 commit 0f9e863

2 files changed

Lines changed: 184 additions & 2 deletions

File tree

app/Services/PostingAutofillService.php

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,16 @@ protected function matchFields(array $aiData, int $personId): array {
317317
]);
318318
}
319319

320+
// 先取出 addr_str 的 admin_type,供官名消歧使用
321+
// (例如「同知」在府、州、縣語境下應對應不同的官名)
322+
$addrAdminType = null;
323+
if (!empty($aiData['addr_str']) && is_array($aiData['addr_str'])) {
324+
$addrAdminType = $aiData['addr_str']['admin_type'] ?? null;
325+
}
326+
320327
// 1. 官名匹配(title_str)
321328
if (!empty($aiData['title_str'])) {
322-
$officeMatch = $this->fuzzyMatchOffice($aiData['title_str'], $effectiveDynasty);
329+
$officeMatch = $this->fuzzyMatchOffice($aiData['title_str'], $effectiveDynasty, $addrAdminType);
323330
if ($officeMatch) {
324331
// 根據匹配類型決定是確認匹配還是建議
325332
if ($officeMatch['match_type'] === 'exact') {
@@ -521,9 +528,10 @@ protected function matchFields(array $aiData, int $personId): array {
521528
*
522529
* @param string $officeName 官名
523530
* @param int|null $dynastyCode 朝代代碼(用於過濾)
531+
* @param string|null $adminType 地址的行政層級(府/州/縣),用於消歧泛稱官名
524532
* @return array|null ['id' => int, 'text' => string, 'match_type' => 'exact'|'fuzzy']
525533
*/
526-
protected function fuzzyMatchOffice(string $officeName, ?int $dynastyCode = null): ?array {
534+
protected function fuzzyMatchOffice(string $officeName, ?int $dynastyCode = null, ?string $adminType = null): ?array {
527535
// ========== Step 1: 精確匹配 c_office_chn ==========
528536
$query = DB::table('OFFICE_CODES')
529537
->select('c_office_id as id', 'c_office_chn as text')
@@ -542,6 +550,17 @@ protected function fuzzyMatchOffice(string $officeName, ?int $dynastyCode = null
542550
];
543551
}
544552

553+
// ========== Step 1.5: 根據 admin_type 消歧泛稱官名 ==========
554+
// 僅在朝代專屬精確匹配(Step 1)落空時才觸發。
555+
// 例如宋代 effectiveDynasty=15 時,OFFICE_CODES 沒有 c_dy=15 且 c_office_chn='同知' 的記錄,
556+
// 若直接落入 Step 2 的 alt 匹配,會誤命中「同知樞密院事」等泛稱 alt 清單。
557+
// 此處針對已知的泛稱(如同知)依 admin_type 對應抽象官名(如同知某府軍府事)。
558+
// 放在 Step 1 之後可保留明清等朝代已有的「同知」專屬記錄,避免覆蓋朝代專屬精確匹配。
559+
$disambiguated = $this->disambiguateOfficeByAdminType($officeName, $adminType);
560+
if ($disambiguated !== null) {
561+
return $disambiguated;
562+
}
563+
545564
// ========== Step 2: 精確匹配 c_office_chn_alt(分號分割) ==========
546565
// 使用 SQL 模式匹配來找到可能包含該官名的記錄
547566
$query = DB::table('OFFICE_CODES')
@@ -660,6 +679,59 @@ protected function fuzzyMatchOffice(string $officeName, ?int $dynastyCode = null
660679
return null;
661680
}
662681

682+
/**
683+
* 根據地址 admin_type 對泛稱官名進行消歧。
684+
*
685+
* AI 有時只抽出泛稱(例如「同知」),需要結合地名的行政層級才能對應到正確的抽象官名:
686+
* - 府同知 → 同知某府軍府事(c_office_id = 6974)
687+
* - 州同知、縣同知 → 同知某州軍州事(c_office_id = 3301)
688+
*
689+
* 這類抽象官名在 OFFICE_CODES 中有固定的 c_office_chn,使用 where 精確查詢,
690+
* 不受朝代過濾影響(抽象官名供各朝代檢索使用)。
691+
*
692+
* @return array|null ['id' => int, 'text' => string, 'match_type' => 'exact']
693+
*/
694+
protected function disambiguateOfficeByAdminType(string $officeName, ?string $adminType): ?array {
695+
if ($adminType === null || $adminType === '') {
696+
return null;
697+
}
698+
699+
$map = [
700+
'同知' => [
701+
'' => '同知某府軍府事',
702+
'' => '同知某州軍州事',
703+
'' => '同知某州軍州事',
704+
],
705+
];
706+
707+
if (!isset($map[$officeName][$adminType])) {
708+
return null;
709+
}
710+
711+
$targetName = $map[$officeName][$adminType];
712+
$row = DB::table('OFFICE_CODES')
713+
->select('c_office_id as id', 'c_office_chn as text')
714+
->where('c_office_chn', '=', $targetName)
715+
->first();
716+
717+
if (!$row) {
718+
return null;
719+
}
720+
721+
Log::info('[AI Autofill] 官名消歧命中', [
722+
'office_name' => $officeName,
723+
'admin_type' => $adminType,
724+
'resolved_to' => $targetName,
725+
'office_id' => $row->id,
726+
]);
727+
728+
return [
729+
'id' => $row->id,
730+
'text' => $row->text,
731+
'match_type' => 'exact',
732+
];
733+
}
734+
663735
/**
664736
* 正規化 addr_str:當 name 剝離 admin_type 後只剩一個字時,將 admin_type 補回 name。
665737
*
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Services\PostingAutofillService;
6+
use Illuminate\Foundation\Testing\RefreshDatabase;
7+
use Illuminate\Support\Facades\DB;
8+
use ReflectionClass;
9+
use Tests\TestCase;
10+
11+
/**
12+
* 回歸測試:fuzzyMatchOffice 對泛稱官名「同知」的消歧順序。
13+
*
14+
* 修正前的 bug:「同知」會被 Step 2 的 c_office_chn_alt 匹配誤命中「同知樞密院事」。
15+
* 修正方式:在 Step 1(朝代專屬精確匹配)後、Step 2(alt 匹配)前加入
16+
* admin_type 消歧,只在朝代專屬精確匹配落空時才將泛稱導向抽象官名。
17+
*
18+
* 這組測試同時保護兩個不變量:
19+
* 1. 宋代等找不到朝代專屬「同知」記錄時,會根據地址 admin_type 導向
20+
* 「同知某府軍府事」/「同知某州軍州事」。
21+
* 2. 明清等已有朝代專屬「同知」記錄時,消歧不會蓋掉朝代專屬匹配
22+
* (避免 Step 1.5 被人提前到 Step 1 之前)。
23+
*/
24+
class PostingAutofillOfficeMatchTest extends TestCase {
25+
use RefreshDatabase;
26+
27+
protected function setUp(): void {
28+
parent::setUp();
29+
30+
DB::table('DYNASTIES')->insert([
31+
['c_dy' => 15, 'c_dynasty_chn' => '', 'c_dynasty' => 'Song', 'c_start' => 960, 'c_end' => 1279, 'c_sort' => 15],
32+
['c_dy' => 19, 'c_dynasty_chn' => '', 'c_dynasty' => 'Ming', 'c_start' => 1368, 'c_end' => 1644, 'c_sort' => 19],
33+
['c_dy' => 20, 'c_dynasty_chn' => '', 'c_dynasty' => 'Qing', 'c_start' => 1644, 'c_end' => 1911, 'c_sort' => 20],
34+
]);
35+
36+
DB::table('OFFICE_CODES')->insert([
37+
// 宋代:泛稱「同知」會出現在 107 的 alt 清單,用以重現舊版誤匹配。
38+
['c_office_id' => 107, 'c_dy' => 15, 'c_office_chn' => '同知樞密院事', 'c_office_chn_alt' => '同知院事;同知;同知樞'],
39+
['c_office_id' => 3301, 'c_dy' => 15, 'c_office_chn' => '同知某州軍州事', 'c_office_chn_alt' => '同知;郡副'],
40+
['c_office_id' => 6974, 'c_dy' => 15, 'c_office_chn' => '同知某府軍府事', 'c_office_chn_alt' => null],
41+
// 明清:朝代專屬的「同知」精確記錄。
42+
['c_office_id' => 70974, 'c_dy' => 19, 'c_office_chn' => '同知', 'c_office_chn_alt' => null],
43+
['c_office_id' => 85485, 'c_dy' => 20, 'c_office_chn' => '同知', 'c_office_chn_alt' => '候補同知;候選同知'],
44+
]);
45+
}
46+
47+
/**
48+
* 呼叫 protected fuzzyMatchOffice,避免走完整 HTTP 流程。
49+
*/
50+
protected function matchOffice(string $officeName, ?int $dynastyCode, ?string $adminType): ?array {
51+
$service = app(PostingAutofillService::class);
52+
$ref = new ReflectionClass($service);
53+
$method = $ref->getMethod('fuzzyMatchOffice');
54+
$method->setAccessible(true);
55+
56+
return $method->invoke($service, $officeName, $dynastyCode, $adminType);
57+
}
58+
59+
public function test_song_tongzhi_with_fu_resolves_to_abstract_fu_office() {
60+
$result = $this->matchOffice('同知', 15, '');
61+
62+
$this->assertNotNull($result);
63+
$this->assertSame(6974, $result['id']);
64+
$this->assertSame('同知某府軍府事', $result['text']);
65+
$this->assertSame('exact', $result['match_type']);
66+
}
67+
68+
public function test_song_tongzhi_with_zhou_resolves_to_abstract_zhou_office() {
69+
$result = $this->matchOffice('同知', 15, '');
70+
71+
$this->assertNotNull($result);
72+
$this->assertSame(3301, $result['id']);
73+
$this->assertSame('同知某州軍州事', $result['text']);
74+
}
75+
76+
public function test_song_tongzhi_with_xian_resolves_to_abstract_zhou_office() {
77+
$result = $this->matchOffice('同知', 15, '');
78+
79+
$this->assertNotNull($result);
80+
$this->assertSame(3301, $result['id']);
81+
}
82+
83+
public function test_ming_tongzhi_with_fu_prefers_dynasty_specific_record() {
84+
// 明代有朝代專屬「同知」(70974),Step 1 應優先命中,
85+
// 不得因 admin_type=府 而退化到宋代 6974。
86+
$result = $this->matchOffice('同知', 19, '');
87+
88+
$this->assertNotNull($result);
89+
$this->assertSame(70974, $result['id']);
90+
$this->assertSame('同知', $result['text']);
91+
}
92+
93+
public function test_qing_tongzhi_with_fu_prefers_dynasty_specific_record() {
94+
$result = $this->matchOffice('同知', 20, '');
95+
96+
$this->assertNotNull($result);
97+
$this->assertSame(85485, $result['id']);
98+
}
99+
100+
public function test_song_tongzhi_without_admin_type_does_not_hit_disambiguation() {
101+
// 沒有 admin_type 時,消歧步驟不應觸發;維持既有(非此次修正範圍的)行為。
102+
// 此處僅驗證不會回傳 6974/3301 抽象官名,不對具體命中做斷言。
103+
$result = $this->matchOffice('同知', 15, null);
104+
105+
if ($result !== null) {
106+
$this->assertNotContains($result['id'], [6974, 3301]);
107+
}
108+
$this->assertTrue(true);
109+
}
110+
}

0 commit comments

Comments
 (0)