2026-05-17 22:43:47 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Infrastructure\AI\Agent;
|
|
|
|
|
|
|
|
|
|
use App\Infrastructure\AI\OllamaClientInterface;
|
2026-05-18 07:19:02 +00:00
|
|
|
use App\Infrastructure\AI\PromptTemplateService;
|
2026-05-18 08:35:52 +00:00
|
|
|
use App\Infrastructure\Search\WebSearchInterface;
|
2026-05-17 22:43:47 +00:00
|
|
|
|
|
|
|
|
final class SpecsResearchAgent
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
2026-05-18 07:19:02 +00:00
|
|
|
private readonly OllamaClientInterface $client,
|
|
|
|
|
private readonly PromptTemplateService $prompts,
|
2026-05-18 08:35:52 +00:00
|
|
|
private readonly WebSearchInterface $search,
|
2026-05-17 22:43:47 +00:00
|
|
|
private readonly string $model,
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 11:00:41 +00:00
|
|
|
/**
|
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type
Frappe ERP:
- findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup
- FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16)
- buildItemDescription() uses specsText + inventory number + serial number
- Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown
- FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration
Pipeline — model cache:
- PhotoUploadHandler: after vision, check DB for existing article with same modelNumber
- On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps
- DraftArticleHandler: apply model_match data and mark job complete directly
- ArticleRepository: findCompletedByModelNumber() query
Pipeline — specs by article type:
- SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var
- SpecsResearchHandler: derive attribute names from ArticleType, pass to agent
- SpecsResearchMessage: add attributeFields param
- Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder
Article:
- specsText field (nullable text column + migration)
- stock field visible on index and editable in CRUD form
- addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding
- AttributeValue::getArticle() getter
- AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true
- ManualIngestType: add stock/quantity field (default 1, min 1)
Users / ACL:
- PermissionVoter: define named permission constants + allPermissions()
- User: getGrantedPermissions()/setGrantedPermissions() helpers
- UserCrudController: permissions checkbox group on edit form
UI / assets:
- public/css/admin/custom.css: red asterisk for required fields
- DashboardController: register custom CSS
Infra:
- PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries
- doctrine.yaml: exclude app.inventory_seq from schema diff
- ErpAdapterInterface: add findExistingCustomer()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
|
|
|
* @param list<string> $attributeFields Attribute names defined for this article type
|
|
|
|
|
*
|
2026-05-18 11:00:41 +00:00
|
|
|
* @return array{specsText: string, correctedModelNumber: string}
|
|
|
|
|
*/
|
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type
Frappe ERP:
- findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup
- FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16)
- buildItemDescription() uses specsText + inventory number + serial number
- Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown
- FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration
Pipeline — model cache:
- PhotoUploadHandler: after vision, check DB for existing article with same modelNumber
- On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps
- DraftArticleHandler: apply model_match data and mark job complete directly
- ArticleRepository: findCompletedByModelNumber() query
Pipeline — specs by article type:
- SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var
- SpecsResearchHandler: derive attribute names from ArticleType, pass to agent
- SpecsResearchMessage: add attributeFields param
- Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder
Article:
- specsText field (nullable text column + migration)
- stock field visible on index and editable in CRUD form
- addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding
- AttributeValue::getArticle() getter
- AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true
- ManualIngestType: add stock/quantity field (default 1, min 1)
Users / ACL:
- PermissionVoter: define named permission constants + allPermissions()
- User: getGrantedPermissions()/setGrantedPermissions() helpers
- UserCrudController: permissions checkbox group on edit form
UI / assets:
- public/css/admin/custom.css: red asterisk for required fields
- DashboardController: register custom CSS
Infra:
- PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries
- doctrine.yaml: exclude app.inventory_seq from schema diff
- ErpAdapterInterface: add findExistingCustomer()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
|
|
|
public function research(string $modelName, string $articleTypeName, string $manufacturer = '', array $attributeFields = []): array
|
2026-05-17 22:43:47 +00:00
|
|
|
{
|
2026-05-18 07:19:02 +00:00
|
|
|
$subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName);
|
2026-05-17 22:43:47 +00:00
|
|
|
|
2026-05-18 08:35:52 +00:00
|
|
|
$searchResults = $this->search->search("{$subject} {$articleTypeName} specifications");
|
|
|
|
|
|
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type
Frappe ERP:
- findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup
- FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16)
- buildItemDescription() uses specsText + inventory number + serial number
- Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown
- FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration
Pipeline — model cache:
- PhotoUploadHandler: after vision, check DB for existing article with same modelNumber
- On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps
- DraftArticleHandler: apply model_match data and mark job complete directly
- ArticleRepository: findCompletedByModelNumber() query
Pipeline — specs by article type:
- SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var
- SpecsResearchHandler: derive attribute names from ArticleType, pass to agent
- SpecsResearchMessage: add attributeFields param
- Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder
Article:
- specsText field (nullable text column + migration)
- stock field visible on index and editable in CRUD form
- addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding
- AttributeValue::getArticle() getter
- AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true
- ManualIngestType: add stock/quantity field (default 1, min 1)
Users / ACL:
- PermissionVoter: define named permission constants + allPermissions()
- User: getGrantedPermissions()/setGrantedPermissions() helpers
- UserCrudController: permissions checkbox group on edit form
UI / assets:
- public/css/admin/custom.css: red asterisk for required fields
- DashboardController: register custom CSS
Infra:
- PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries
- doctrine.yaml: exclude app.inventory_seq from schema diff
- ErpAdapterInterface: add findExistingCustomer()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
|
|
|
$fieldsList = [] !== $attributeFields
|
|
|
|
|
? implode("\n", array_map(static fn (string $f) => "- {$f}", $attributeFields))
|
|
|
|
|
: '- all relevant technical specifications';
|
|
|
|
|
|
2026-05-18 07:19:02 +00:00
|
|
|
$prompt = $this->prompts->render('specs_research', [
|
|
|
|
|
'articleType' => $articleTypeName,
|
|
|
|
|
'subject' => $subject,
|
2026-05-18 08:35:52 +00:00
|
|
|
'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.',
|
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type
Frappe ERP:
- findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup
- FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16)
- buildItemDescription() uses specsText + inventory number + serial number
- Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown
- FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration
Pipeline — model cache:
- PhotoUploadHandler: after vision, check DB for existing article with same modelNumber
- On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps
- DraftArticleHandler: apply model_match data and mark job complete directly
- ArticleRepository: findCompletedByModelNumber() query
Pipeline — specs by article type:
- SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var
- SpecsResearchHandler: derive attribute names from ArticleType, pass to agent
- SpecsResearchMessage: add attributeFields param
- Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder
Article:
- specsText field (nullable text column + migration)
- stock field visible on index and editable in CRUD form
- addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding
- AttributeValue::getArticle() getter
- AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true
- ManualIngestType: add stock/quantity field (default 1, min 1)
Users / ACL:
- PermissionVoter: define named permission constants + allPermissions()
- User: getGrantedPermissions()/setGrantedPermissions() helpers
- UserCrudController: permissions checkbox group on edit form
UI / assets:
- public/css/admin/custom.css: red asterisk for required fields
- DashboardController: register custom CSS
Infra:
- PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries
- doctrine.yaml: exclude app.inventory_seq from schema diff
- ErpAdapterInterface: add findExistingCustomer()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
|
|
|
'fields' => $fieldsList,
|
2026-05-18 07:19:02 +00:00
|
|
|
]);
|
2026-05-17 22:43:47 +00:00
|
|
|
|
2026-05-18 08:35:52 +00:00
|
|
|
$result = $this->client->generate($this->model, $prompt);
|
2026-05-17 22:43:47 +00:00
|
|
|
|
2026-05-18 07:19:02 +00:00
|
|
|
if ('' === trim($result)) {
|
|
|
|
|
throw new \RuntimeException("No specifications found for model: {$modelName}");
|
|
|
|
|
}
|
2026-05-17 22:43:47 +00:00
|
|
|
|
2026-05-18 11:00:41 +00:00
|
|
|
return $this->parseResponse($result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return array{specsText: string, correctedModelNumber: string} */
|
|
|
|
|
private function parseResponse(string $raw): array
|
|
|
|
|
{
|
|
|
|
|
$correctedModelNumber = '';
|
|
|
|
|
|
|
|
|
|
if (preg_match('/^CORRECTED_MODEL_NUMBER:\s*(\S+)/m', $raw, $matches)) {
|
|
|
|
|
$correctedModelNumber = trim($matches[1]);
|
|
|
|
|
// Strip the line from the specs text
|
|
|
|
|
$raw = preg_replace('/^CORRECTED_MODEL_NUMBER:[^\n]*\n?/m', '', $raw) ?? $raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'specsText' => trim($raw),
|
|
|
|
|
'correctedModelNumber' => $correctedModelNumber,
|
|
|
|
|
];
|
2026-05-17 22:43:47 +00:00
|
|
|
}
|
|
|
|
|
}
|