File size: 6,525 Bytes
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a32af27
 
 
 
1f5f31a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e740385
a32af27
1f5f31a
 
e740385
 
a32af27
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
-- VenThrive business-news system β€” run this once against your Supabase project's
-- SQL editor (or `psql`). See docs/blueprint/adr/0015-business-news-system-design.md
-- for the reasoning behind every choice below.

create extension if not exists pgcrypto;

-- ─── Sources ────────────────────────────────────────────────────────────────
-- Sources are data, not code, so an admin can add/disable one without a deploy.
-- 'rss' sources are polled with feedparser; 'html' sources with a generic
-- title + first-paragraph extractor (see backend/news/scrape.py).
create table if not exists news_sources (
  id            uuid primary key default gen_random_uuid(),
  name          text not null,
  url           text not null unique,
  source_type   text not null check (source_type in ('rss', 'html')),
  -- Which of the three sourcing strategies this feeds (ADR-0015) β€” purely
  -- informational, doesn't change scrape behavior.
  source_group  text not null check (source_group in ('national_news', 'agency_bulletin', 'funder_program')),
  active        boolean not null default true,
  created_at    timestamptz not null default now()
);

-- A handful of real, well-known starter sources per ADR-0015's three source
-- groups. Add more through this table directly β€” no code change needed.
insert into news_sources (name, url, source_type, source_group) values
  ('BusinessDay',               'https://businessday.ng/feed/',                 'rss',  'national_news'),
  ('Nairametrics',              'https://nairametrics.com/feed/',               'rss',  'national_news'),
  ('Premium Times β€” Business',  'https://www.premiumtimesng.com/category/business/feed', 'rss', 'national_news'),
  ('NiMet β€” Weather Alerts',    'https://www.nimet.gov.ng',                     'html', 'agency_bulletin'),
  ('NEMA β€” Advisories',         'https://nema.gov.ng',                          'html', 'agency_bulletin'),
  ('SMEDAN',                    'https://smedan.gov.ng',                        'html', 'funder_program'),
  ('Bank of Industry β€” News',   'https://www.boi.ng/category/news/',            'html', 'funder_program'),
  ('Tony Elumelu Foundation',   'https://www.tonyelumelufoundation.org/news',   'html', 'funder_program')
on conflict (url) do nothing;

-- ─── Items ──────────────────────────────────────────────────────────────────
create table if not exists news_items (
  id              uuid primary key default gen_random_uuid(),
  source_id       uuid references news_sources(id) on delete set null,
  url             text not null,
  url_hash        text not null unique,
  title           text not null,
  raw_text        text not null default '',
  published_at    timestamptz,
  scraped_at      timestamptz not null default now(),

  status          text not null default 'pending'
                    check (status in ('pending', 'classified', 'needs_review', 'rejected')),

  -- Classification output (ADR-0015) β€” all null until classified. No 'threat' value:
  -- reporting something bad that already happened has no value once it can't be acted
  -- on; a predictive risk system is a separate, forward-looking feature.
  impact          text check (impact in ('opportunity', 'neutral')),
  category        text check (category in ('policy', 'weather', 'infrastructure', 'funding', 'security', 'other')),
  business_types  text[],          -- subsector ids from data/sectors.ts, see backend/news/subsectors.json
  location        text,
  deadline        date,            -- only set for an opportunity with an explicit deadline in the source text
  expires_at      timestamptz,     -- deadline if set, else scraped_at + the category's default window
  confidence      numeric,
  review_reason   text,            -- why this needs_review, for the admin queue
  classified_at   timestamptz,

  -- The completeness bar from ADR-0015, enforced here, not just in application
  -- code: a classified item must have a category, at least one business type,
  -- and a location.
  constraint classified_items_are_complete check (
    status <> 'classified'
    or (category is not null and location is not null
        and business_types is not null and array_length(business_types, 1) > 0)
  )
);

create index if not exists news_items_status_idx on news_items (status);
create index if not exists news_items_expires_at_idx on news_items (expires_at);
create index if not exists news_items_business_types_idx on news_items using gin (business_types);

-- ─── Poll run log ───────────────────────────────────────────────────────────
-- One row per admin-triggered poll (ADR-0015: admin-triggered only, daily
-- auto-poll deferred) β€” an audit trail and a place to surface run-level errors.
create table if not exists news_polls (
  id                 uuid primary key default gen_random_uuid(),
  triggered_by       text not null,   -- admin's Firebase UID
  since_days         int not null check (since_days between 1 and 30),
  started_at         timestamptz not null default now(),
  finished_at        timestamptz,
  items_scraped      int not null default 0,
  items_deduped_out  int not null default 0,
  items_classified   int not null default 0,
  items_flagged      int not null default 0,
  items_rejected     int not null default 0,  -- auto-discarded: incomplete and the model wasn't confident either
  current_source     text,  -- name of the source being scraped right now, for a live run console
  error              text
);

alter table news_polls add column if not exists items_rejected int not null default 0;
alter table news_polls add column if not exists current_source text;

-- 'threat' removed from impact (ADR-0015 addendum): a reactive "this already happened"
-- label has no value to a business owner who can't act on it anymore. Existing rows
-- are reclassified neutral; a predictive risk/forecast system is planned separately.
update news_items set impact = 'neutral' where impact = 'threat';
alter table news_items drop constraint if exists news_items_impact_check;
alter table news_items add constraint news_items_impact_check check (impact in ('opportunity', 'neutral'));