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