feat: preserve grounded sector ideas through private investment dossiers in QA

This commit is contained in:
2026-11-15 20:31:57 +02:00
parent 25d18f51bb
commit 6e7dfdabd0
17 changed files with 882 additions and 36 deletions
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace App\Services;
use App\Models\User;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** An adapter over published, versioned articles; never another opportunity register. */
final class SectorContentAnalysis
{
public const REGIONS = [172263 => 'Zambézia', 172256 => 'Sofala', 172261 => 'Tete',
172258 => 'Manica', 172253 => 'Inhambane', 172240 => 'Maputo Cidade',
172237 => 'Maputo Província', 172233 => 'Gaza', 172230 => 'Nampula',
172223 => 'Cabo Delgado', 172219 => 'Niassa'];
private const ACTIVITIES = [
'turismo' => ['Hotelaria e alojamento' => '/hotel|alojamento|lodge|resort/iu', 'Turismo de natureza' => '/ecoturismo|eco.turismo|conservação/iu', 'Cultura e experiências' => '/turismo cultural|gastronomia|turismo gastronómico/iu'],
'agronegocio' => ['Produção agrícola' => '/produção agrícola/iu', 'Processamento agro-industrial' => '/processamento|transformação agro/iu', 'Armazenagem e logística' => '/armazenagem|logística/iu'],
'infraestruturas' => ['Água e saneamento' => '/água|saneamento/iu', 'Transportes e logística' => '/transportes|logística/iu', 'Construção e modernização' => '/construção|modernização/iu'],
'mineracao' => ['Processamento mineral' => '/processamento|beneficiamento/iu', 'Serviços à mineração' => '/serviços especializados|manutenção/iu', 'Logística mineira' => '/logística|transporte/iu'],
'energia-especifica' => ['Geração de energia' => '/geração|electricidade|el[eé]ctrica/iu', 'Distribuição e fornecimento' => '/distribuição|fornecimento/iu', 'Transformação industrial' => '/transformação industrial|fertilizantes|downstream/iu'],
];
/** Read every paragraph/list item in the consulted edition; headings are citation context.
* @param array<string,mixed> $article
* @return array<string,mixed>
*/
public function describe(array $article): array
{
$sector = $article['sector']['slug'] ?? '';
if (! isset(self::ACTIVITIES[$sector])) {
return ['ideas' => [], 'blocks' => []];
}
$doc = new DOMDocument;
$previous = libxml_use_internal_errors(true);
try {
$doc->loadHTML('<?xml encoding="UTF-8"><html><body>'.$article['html'].'</body></html>', LIBXML_NONET);
$nodes = (new DOMXPath($doc))->query('//h2|//h3|//h4|//p[not(ancestor::li)]|//li[not(descendant::li)]');
$blocks = [];
$section = '';
$occurrences = [];
if ($nodes === false) {
throw new \RuntimeException('Não foi possível analisar o conteúdo sectorial.');
}
foreach ($nodes as $node) {
if (! $node instanceof \DOMElement) {
continue;
}
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent));
if ($text === '') {
continue;
}
if (str_starts_with($node->nodeName, 'h')) {
$section = $text;
continue;
}
$hash = hash('sha256', $section."\n".$text);
$occurrences[$hash] = ($occurrences[$hash] ?? 0) + 1;
$blocks[] = ['key' => $hash.':'.$occurrences[$hash], 'section' => $section, 'quote' => $text];
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$region = self::REGIONS[$article['legacy_id']] ?? null;
$ideas = [];
foreach (self::ACTIVITIES[$sector] as $label => $pattern) {
// Exact supporting passage, never a conclusion about demand or return.
$block = array_find($blocks, fn ($b) => preg_match($pattern, $b['quote']) === 1);
if (! $block) {
continue;
}
$ideas[] = ['title' => $label, 'classification' => 'hypothesis', 'evidence' => $block,
'location_label' => $region, 'location_precision' => $region ? 'province' : 'source_scope',
'start_href' => $article['start_href'].'?'.http_build_query(['evidence' => $block['key'], 'source_sha256' => $article['sha256'], 'source_version' => $article['version'], 'idea' => $label])];
}
return ['ideas' => $ideas, 'blocks' => $blocks, 'region' => $region, 'sector_slug' => $sector,
'source_date' => $article['modified_at'], 'source_version' => $article['version'],
'notice' => 'Possibilidades para estudar, não anúncios abertos. A fonte descreve o contexto; procura, concorrência, preços, direitos e rentabilidade exigem análise própria.'];
}
/** Resolve identifiers against the current published bytes, not a client-supplied quotation.
* @param array<string,mixed> $article
* @param array<string,mixed> $input
* @return array<string,mixed>|null
*/
public function selection(array $article, array $input): ?array
{
if (empty($input['evidence'])) {
return null;
}
$v = Validator::make($input, ['evidence' => ['required', 'regex:/^[a-f0-9]{64}:\d+$/D'],
'source_sha256' => 'required|string|size:64', 'source_version' => 'required|integer|min:1', 'idea' => 'required|string|max:200'])->validate();
abort_unless(hash_equals($article['sha256'], $v['source_sha256']) && (int) $v['source_version'] === (int) $article['version'], 409, 'O conteúdo mudou. Consulte a edição actual antes de continuar.');
$analysis = $this->describe($article);
$idea = array_find($analysis['ideas'], fn ($i) => $i['title'] === $v['idea'] && $i['evidence']['key'] === $v['evidence']);
abort_unless($idea, 422, 'Escolha uma possibilidade sustentada pelo conteúdo consultado.');
return ['key' => hash('sha256', $article['id'].':'.$article['version'].$article['sha256'].$v['evidence'].$v['idea']),
'title' => $v['idea'], 'evidence_key' => $v['evidence'], 'classification' => 'hypothesis', 'evidence_classification' => 'documented_source_statement',
'quote' => $idea['evidence']['quote'], 'section' => $idea['evidence']['section'],
'source_sha256' => $article['sha256'], 'source_version' => $article['version'], 'source_date' => $article['modified_at'],
'content_sha256' => hash('sha256', $article['html']),
'resource_id' => $article['id'], 'sector' => $article['sector'], 'location_label' => $analysis['region'],
'location_precision' => $analysis['region'] ? 'province' : 'source_scope',
'href' => $article['href'], 'open_participation' => false];
}
/** Save the immutable passage inside the existing dossier source; do not overwrite declarations.
* @param array<string,mixed> $input
* @return array<string,mixed>|null
*/
public function attach(User $actor, string $id, array $input): ?array
{
$article = app(LegacyContent::class)->articleForResource((int) ($input['resource_id'] ?? 0));
$selection = $this->selection($article, $input);
if (! $selection) {
app(LegacyContent::class)->attach($actor, $id, $input);
return null;
}
return DB::transaction(function () use ($actor, $id, $input, $selection) {
$desk = app(VirtualDesk::class);
$case = $desk->case($actor, $id, true, true);
abort_unless((int) $case->version === (int) ($input['version'] ?? 0), 409, 'O dossier mudou. Recupere-o antes de continuar.');
app(LegacyContent::class)->attach($actor, $id, $input);
$case = $desk->case($actor, $id, true, true);
$payload = $desk->decode($case->payload);
foreach ($payload['investor_path']['content_sources'] as &$source) {
if ($source['resource_id'] !== $selection['resource_id'] || $source['sha256'] !== $selection['source_sha256']) {
continue;
}
if (array_any($source['analyses'] ?? [], fn ($a) => $a['key'] === $selection['key'])) {
return $selection;
}
$source['analyses'][] = [...$selection, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()];
}
unset($source);
DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
$desk->event($desk->case($actor, $id), $actor, 'sector_analysis_referenced', ['key' => $selection['key'], 'resource_id' => $selection['resource_id']]);
return $selection;
});
}
/** Propose only the expressly identified province; district and exact position stay unset.
* @param array<string,mixed> $selection
*/
public function continuation(string $id, array $selection): string
{
$query = ['sector_id' => $selection['sector']['id']];
if ($selection['location_label']) {
$matches = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->get(['id', 'name'])
->filter(fn ($p) => Str::lower(Str::ascii($p->name)) === Str::lower(Str::ascii($selection['location_label'])));
if ($matches->count() === 1) {
$query['province_id'] = $matches->first()->id;
}
}
return '/investimentos/'.$id.'/contexto/editar?'.http_build_query($query);
}
/** The saved quotation survives source changes/withdrawal; never replace it silently.
* @param list<array<string,mixed>> $sources
* @return list<array<string,mixed>>
*/
public function savedSources(array $sources): array
{
foreach ($sources as &$source) {
if (empty($source['analyses'])) {
continue;
}
try {
$current = app(LegacyContent::class)->articleForResource($source['resource_id']);
$source['analysis_review_required'] = array_any($source['analyses'], fn ($a) => $a['source_version'] !== $current['version'] || ! hash_equals($a['source_sha256'], $current['sha256'])
|| ! hash_equals($a['content_sha256'], hash('sha256', $current['html'])));
} catch (HttpException $error) {
if (! in_array($error->getStatusCode(), [404, 409, 422], true)) {
throw $error;
}
$source['analysis_review_required'] = true;
}
}
unset($source);
return $sources;
}
/** Provincial reading remains provincial when consulted from a district/locality.
* @param list<string> $ids
* @return list<array<string,mixed>>
*/
public function forTerritories(array $ids): array
{
if (! app(LegacyContent::class)->available() || $ids === []) {
return [];
}
$provinces = [];
foreach ($ids as $id) {
foreach (app(InvestmentGeography::class)->path($id) as $node) {
if ($node->location_type === 'province') {
$provinces[] = Str::lower(Str::ascii($node->name));
}
}
}
$result = [];
foreach (config('legacy_content.articles', []) as $slug => $identity) {
if ($identity['group'] !== 'turismo') {
continue;
}
preg_match('/post-(\d+)-/', $identity['external_id'], $match);
$region = self::REGIONS[(int) ($match[1] ?? 0)] ?? null;
if (! $region || ! in_array(Str::lower(Str::ascii($region)), $provinces, true)) {
continue;
}
try {
$a = app(LegacyContent::class)->article($slug, false);
$result[] = ['title' => $a['title'], 'href' => $a['href'], 'analysis' => $a['sector_analysis']];
} catch (HttpException $error) {
if ($error->getStatusCode() !== 404) {
throw $error;
}
}
}
return $result;
}
}