fix: vision agent serial-bleed regex + fix broken agent unit tests

OllamaVisionAgent.extractField() now handles field labels at the start
of a value (e.g. MODEL_NUMBER: "SERIAL: 1005NK677594" -> "") not just
mid-value bleed. Both agent test files updated to mock
PromptTemplateRepositoryInterface and construct a real
PromptTemplateService, since the service is final and unmockable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 16:57:01 +00:00
parent c19637465b
commit 376171303e
3 changed files with 61 additions and 14 deletions

View file

@ -39,8 +39,9 @@ final class OllamaVisionAgent
$value = trim($matches[1]); $value = trim($matches[1]);
// Strip any embedded field label the model mistakenly included (e.g. "SERIAL: PNV09SJZ") // Strip any embedded field label the LLM mistakenly included
$value = preg_replace('/\s+[A-Z_]+:.*$/i', '', $value) ?? $value; // Handles both leading labels ("SERIAL: xyz") and mid-value ones ("ThinkBook 14 SERIAL: xyz")
$value = preg_replace('/(?:^|\s+)[A-Z_]+:.*$/i', '', $value) ?? $value;
return trim($value); return trim($value);
} }

View file

@ -7,8 +7,11 @@ namespace App\Tests\Unit\Infrastructure\AI\Agent;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition; use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType; use App\Domain\Article\AttributeType;
use App\Domain\AI\PromptTemplate;
use App\Domain\AI\Repository\PromptTemplateRepositoryInterface;
use App\Infrastructure\AI\Agent\JsonCodingAgent; use App\Infrastructure\AI\Agent\JsonCodingAgent;
use App\Infrastructure\AI\OllamaClientInterface; use App\Infrastructure\AI\OllamaClientInterface;
use App\Infrastructure\AI\PromptTemplateService;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -21,7 +24,12 @@ final class JsonCodingAgentTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->ollama = $this->createMock(OllamaClientInterface::class); $this->ollama = $this->createMock(OllamaClientInterface::class);
$this->agent = new JsonCodingAgent($this->ollama, 'llama3.2');
$repo = $this->createMock(PromptTemplateRepositoryInterface::class);
$repo->method('findByKey')->willReturn(new PromptTemplate('json_coding', '{{schema}}{{missingHint}}{{specsText}}'));
$prompts = new PromptTemplateService($repo);
$this->agent = new JsonCodingAgent($this->ollama, $prompts, 'llama3.2');
$this->type = new ArticleType('Notebook'); $this->type = new ArticleType('Notebook');
$ramDef = new AttributeDefinition('RAM', AttributeType::String); $ramDef = new AttributeDefinition('RAM', AttributeType::String);
$this->type->addAttributeDefinition($ramDef); $this->type->addAttributeDefinition($ramDef);

View file

@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\AI\Agent; namespace App\Tests\Unit\Infrastructure\AI\Agent;
use App\Domain\AI\PromptTemplate;
use App\Domain\AI\Repository\PromptTemplateRepositoryInterface;
use App\Infrastructure\AI\Agent\OllamaVisionAgent; use App\Infrastructure\AI\Agent\OllamaVisionAgent;
use App\Infrastructure\AI\OllamaClientInterface; use App\Infrastructure\AI\OllamaClientInterface;
use App\Infrastructure\AI\PromptTemplateService;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -17,28 +20,63 @@ final class OllamaVisionAgentTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->ollama = $this->createMock(OllamaClientInterface::class); $this->ollama = $this->createMock(OllamaClientInterface::class);
$this->agent = new OllamaVisionAgent($this->ollama, 'llava');
$repo = $this->createMock(PromptTemplateRepositoryInterface::class);
$repo->method('findByKey')->willReturn(new PromptTemplate('vision_analyze', 'prompt'));
$prompts = new PromptTemplateService($repo);
$this->agent = new OllamaVisionAgent($this->ollama, $prompts, 'llava');
} }
public function testParsesModelAndSerialFromResponse(): void public function test_parses_all_fields(): void
{ {
$this->ollama->method('generateWithImage') $this->ollama->method('generateWithImage')->willReturn(
->willReturn("MODEL: Dell Latitude 5520\nSERIAL: ABC12345"); "MANUFACTURER: Lenovo\nMODEL_NAME: ThinkBook 14 G6 IRL\nMODEL_NUMBER: 21KG00NQGE\nSERIAL: PNV09SJZ"
);
$result = $this->agent->analyze('/tmp/photo.jpg'); $result = $this->agent->analyze('/tmp/photo.jpg');
self::assertSame('Dell Latitude 5520', $result['model']); $this->assertSame('Lenovo', $result['manufacturer']);
self::assertSame('ABC12345', $result['serial']); $this->assertSame('ThinkBook 14 G6 IRL', $result['modelName']);
$this->assertSame('21KG00NQGE', $result['modelNumber']);
$this->assertSame('PNV09SJZ', $result['serial']);
} }
public function testReturnsEmptyStringsWhenNotFound(): void public function test_strips_serial_bleed_into_model_number_leading(): void
{ {
$this->ollama->method('generateWithImage') // LLM outputs "SERIAL: xyz" as the entire value for MODEL_NUMBER
->willReturn('I cannot read the nameplate clearly.'); $this->ollama->method('generateWithImage')->willReturn(
"MANUFACTURER: ELPIDA\nMODEL_NAME: 2GB 2Rx8 PC3-10600S-9-10-F1\nMODEL_NUMBER: SERIAL: 1005NK677594\nSERIAL: 1005NK677594"
);
$result = $this->agent->analyze('/tmp/photo.jpg'); $result = $this->agent->analyze('/tmp/photo.jpg');
self::assertSame('', $result['model']); $this->assertSame('', $result['modelNumber']);
self::assertSame('', $result['serial']); $this->assertSame('1005NK677594', $result['serial']);
}
public function test_strips_serial_bleed_into_model_name_mid_value(): void
{
// LLM appends serial after the model name
$this->ollama->method('generateWithImage')->willReturn(
"MANUFACTURER: Lenovo\nMODEL_NAME: ThinkBook 14 G6 IRL SERIAL: PNV09SJZ\nMODEL_NUMBER: 21KG00NQGE\nSERIAL: PNV09SJZ"
);
$result = $this->agent->analyze('/tmp/photo.jpg');
$this->assertSame('ThinkBook 14 G6 IRL', $result['modelName']);
$this->assertSame('PNV09SJZ', $result['serial']);
}
public function test_returns_empty_strings_when_fields_missing(): void
{
$this->ollama->method('generateWithImage')->willReturn('I cannot read the nameplate.');
$result = $this->agent->analyze('/tmp/photo.jpg');
$this->assertSame('', $result['manufacturer']);
$this->assertSame('', $result['modelName']);
$this->assertSame('', $result['modelNumber']);
$this->assertSame('', $result['serial']);
} }
} }