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
@@ -6,6 +6,7 @@ use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use App\Services\PortalEditorialWorkflow;
use App\Services\SectorContentAnalysis;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,7 +34,7 @@ final class DemographyController extends Controller
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
return Inertia::render('investment/demography', ['sectorReadings' => app(SectorContentAnalysis::class)->forTerritories($data['territories'] ?? []), 'evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
@@ -8,6 +8,7 @@ use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\SectorContentAnalysis;
use App\Services\SectorResourceReferences;
use App\Services\SectorInvestment;
use App\Services\VirtualDesk;
@@ -99,6 +100,8 @@ class InvestorPathController extends Controller
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$analysis = $source !== null ? app(SectorContentAnalysis::class)->selection(app(LegacyContent::class)->articleForResource((int) $source), $request->all()) : null;
abort_if($request->filled('evidence') && $source === null, 422);
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone, $reference): string {
if ($reference !== null) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
@@ -112,7 +115,7 @@ class InvestorPathController extends Controller
}
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
app(SectorContentAnalysis::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source, ...$request->only(['evidence', 'source_sha256', 'source_version', 'idea'])]);
}
if ($sector !== null) {
@@ -128,7 +131,7 @@ class InvestorPathController extends Controller
return $id;
});
$href = $comparison === null ? '/investimentos/'.$id : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
$href = $comparison === null ? ($analysis ? app(SectorContentAnalysis::class)->continuation($id, $analysis) : '/investimentos/'.$id) : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
if ($guide !== null) {
$href = '/investimentos/'.$id.'/guias/'.$guide['slug'].'?'.http_build_query(['current' => 1, 'selection' => 1, 'sha256' => $guide['sha256'], 'instruments' => $guide['instruments']]);
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\PublicSearchMetrics;
use App\Services\SectorContentAnalysis;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -40,12 +41,19 @@ class LegacyContentController extends Controller
abort_unless($path->available(), 404);
$article = $legacy->article($legacySlug);
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
$analysis = app(SectorContentAnalysis::class)->selection($article, $request->all());
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article, 'sectorIdea' => $analysis, 'analysisAction' => $request->query('analysis_action') === 'compare' ? 'compare' : 'prepare']);
}
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
{
$legacy->attach($request->user(), $id, $request->all());
$analysis = app(SectorContentAnalysis::class)->attach($request->user(), $id, $request->all());
if ($analysis) {
$href = app(SectorContentAnalysis::class)->continuation($id, $analysis);
return $request->expectsJson() ? response()->json(['saved' => true, 'continue' => $href]) : redirect($href);
}
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
}
+1 -1
View File
@@ -189,7 +189,7 @@ final class InvestorPath
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
'content_sources' => ($owner || $manager) ? app(SectorContentAnalysis::class)->savedSources($path['content_sources'] ?? []) : [],
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
->whereNull('deleted_at')
->when($preparationTaskKeys !== null, fn ($q) => $q->where(fn ($p) => $p->whereNull('guidance_key')->orWhere('guidance_key', 'not like', 'swot:%')->orWhereIn('guidance_key', $preparationTaskKeys ?? [])))
+4 -1
View File
@@ -104,7 +104,7 @@ final class LegacyContent
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
$article = ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
@@ -124,6 +124,9 @@ final class LegacyContent
'territorial_status' => 'Menções territoriais preservadas no artigo. Limites administrativos, parcelas e geometrias não associados automaticamente.',
'library_href' => '/documentos/'.$row->resource_id,
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
$article['sector_analysis'] = app(SectorContentAnalysis::class)->describe($article);
return $article;
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
+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;
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ final class SectorInvestment
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
$articles[] = Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'sector_analysis', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;