feat: preserve grounded sector ideas through private investment dossiers in QA
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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 ?? [])))
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user