Catalyst / admin/Bid-Sentinel 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Bid-Sentinel

public

Bid Scrape and Tracking Application with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Bid-Sentinel / bid-sentinel-v2 / make_user_guide.py 32879 B · main
  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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
"""Generates USER_GUIDE.pdf for the Bid Sentinel application."""
import os

from reportlab.lib.colors import HexColor
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import (
    HRFlowable,
    Image,
    ListFlowable,
    ListItem,
    Paragraph,
    SimpleDocTemplate,
    Spacer,
    Table,
    TableStyle,
)

LOGO_PATH = os.path.join(os.path.dirname(__file__), "assets", "bid-sentinel-logo.png")

BRAND = HexColor("#0b3d91")
BRAND_LIGHT = HexColor("#1d5fd6")
GREY = HexColor("#475569")

styles = getSampleStyleSheet()

h_title = ParagraphStyle("h_title", parent=styles["Title"], textColor=BRAND, fontSize=26, spaceAfter=4)
h_sub = ParagraphStyle("h_sub", parent=styles["Normal"], textColor=GREY, fontSize=11, spaceAfter=18)
h1 = ParagraphStyle("h1", parent=styles["Heading1"], textColor=BRAND, fontSize=15, spaceBefore=14, spaceAfter=6)
h2 = ParagraphStyle("h2", parent=styles["Heading2"], textColor=BRAND_LIGHT, fontSize=12, spaceBefore=8, spaceAfter=4)
body = ParagraphStyle("body", parent=styles["Normal"], fontSize=10, leading=15, alignment=TA_LEFT, spaceAfter=6)
small = ParagraphStyle("small", parent=styles["Normal"], fontSize=8.5, textColor=GREY)
mono = ParagraphStyle("mono", parent=styles["Code"], fontSize=9, leading=13, backColor=HexColor("#f1f5f9"),
                      borderPadding=6, leftIndent=4)


def bullets(items):
    return ListFlowable(
        [ListItem(Paragraph(t, body), leftIndent=10, value="•") for t in items],
        bulletType="bullet", start="•",
    )


def info_table(rows, col_widths):
    t = Table(rows, colWidths=col_widths, hAlign="LEFT")
    t.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), BRAND),
        ("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#ffffff")),
        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, -1), 9),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [HexColor("#ffffff"), HexColor("#f1f5f9")]),
        ("GRID", (0, 0), (-1, -1), 0.5, HexColor("#cbd5e1")),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("TOPPADDING", (0, 0), (-1, -1), 5),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
        ("LEFTPADDING", (0, 0), (-1, -1), 7),
    ]))
    return t


h_sub_center = ParagraphStyle("h_sub_center", parent=h_sub, alignment=TA_CENTER)

story = []

# --- Cover ---
story.append(Spacer(1, 0.6 * cm))
if os.path.exists(LOGO_PATH):
    iw, ih = ImageReader(LOGO_PATH).getSize()
    disp_w = 9 * cm
    disp_h = disp_w * ih / iw
    logo = Image(LOGO_PATH, width=disp_w, height=disp_h)
    logo.hAlign = "CENTER"
    story.append(logo)
    story.append(Spacer(1, 0.2 * cm))
else:
    story.append(Paragraph("BID SENTINEL", h_title))
story.append(Paragraph("User & Admin Guide", h_sub_center))
story.append(HRFlowable(width="100%", thickness=2, color=BRAND, spaceAfter=10))
story.append(Paragraph(
    "A dockerized platform to scrape, track, and manage UK public and private "
    "sector tender opportunities for Cyber Security Services (MDR, SOC, Incident "
    "Response, Vulnerability Management, CTI, and more).", body))
story.append(Spacer(1, 0.3 * cm))

# --- 1. Getting Started ---
story.append(Paragraph("1. Getting Started", h1))
story.append(Paragraph("Prerequisites", h2))
story.append(bullets([
    "Docker Desktop (or Docker Engine + Docker Compose v2) installed and running.",
    "Ports 8080 (web), 8000 (API), and 5432 (database) free on your machine.",
]))
story.append(Paragraph("Launch the application", h2))
story.append(Paragraph(
    "From the project root, copy the environment template and start the stack:", body))
story.append(Paragraph(
    "cp .env.example .env<br/>docker compose up --build -d<br/>"
    "docker compose logs -f backend", mono))
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph("Then open the dashboard in your browser:", body))
story.append(info_table(
    [["Service", "Address"],
     ["Web Dashboard", "http://localhost:8080"],
     ["API documentation", "http://localhost:8000/docs"],
     ["Health check", "http://localhost:8000/health"]],
    [6 * cm, 9 * cm]))

# --- 2. First-run setup & Logging In ---
story.append(Paragraph("2. First-Run Setup &amp; Logging In", h1))
story.append(Paragraph(
    "Bid Sentinel does not ship with a default account. The <b>first time</b> you "
    "open the dashboard, the login screen detects that no users exist and instead "
    "shows a <b>Create administrator account</b> form.", body))
story.append(Paragraph("Create your administrator", h2))
story.append(ListFlowable([
    ListItem(Paragraph("Enter your full name, email address, and a password "
                       "(minimum 8 characters), then confirm the password.", body), leftIndent=10),
    ListItem(Paragraph("Click <b>Create admin account</b>. You are signed in "
                       "automatically as an administrator.", body), leftIndent=10),
    ListItem(Paragraph("The setup form locks itself permanently once this first "
                       "account exists — afterwards the screen shows the normal "
                       "<b>Sign in</b> form.", body), leftIndent=10),
], bulletType="1"))
story.append(Spacer(1, 0.2 * cm))
story.append(bullets([
    "Your session is stored securely in the browser using a JWT token, so you "
    "stay logged in across page refreshes and browser restarts (persistent session).",
    "Click <b>Sign out</b> in the top-right corner to end your session.",
    "Additional users (admin or standard) can be created later by an "
    "administrator — see section 9.",
]))

# --- 3. The Dashboard ---
story.append(Paragraph("3. Using the Dashboard", h1))
story.append(Paragraph(
    "The dashboard shows a customisable <b>Performance overview</b> at the top, "
    "followed by the full table of cyber-security opportunities.", body))
story.append(Paragraph(
    "<b>Light &amp; dark mode.</b> A sun/moon button in the header (and on the "
    "sign-in screen) switches between light and dark themes. Your choice is "
    "remembered on that browser; first-time visitors follow their operating "
    "system's preference automatically.", body))

story.append(Paragraph("Performance overview (analytics)", h2))
story.append(Paragraph(
    "A strip of KPI cards and charts summarises bid performance and updates "
    "instantly as you change a bid status or outcome. Use the toggles to pivot "
    "the data:", body))
story.append(bullets([
    "<b>KPI cards</b> — Total tracked, Open now, Bidding, Pipeline value, "
    "Closing in 14 days, <b>Win rate</b>, and <b>Value won</b>. Once an "
    "opportunity's outcome is <b>Lost</b> it drops out of the <b>Bidding</b> "
    "count and its amount is removed from <b>Pipeline value</b>, so those cards "
    "always reflect the live pipeline.",
    "<b>Measure</b> toggle — switch every chart between opportunity <b>Count</b> "
    "and total <b>Value (£)</b>.",
    "<b>Date</b> toggle — base the time chart on Published or Closing dates.",
    "<b>Range</b> toggle — 90 days, 12 months, or All time.",
    "<b>Pivot by</b> — break the data down by Portal, Keyword, Buyer, Bid status, "
    "or Outcome.",
    "Charts: bid pipeline by status, opportunities over time, the configurable "
    "pivot breakdown, and demand by service line. Use <b>Hide</b> to collapse the "
    "whole panel.",
]))

story.append(Paragraph("Table columns", h2))
story.append(info_table(
    [["Column", "Description"],
     ["Opportunity Name", "Title, buyer, matched keyword, and detected certifications"],
     ["Fit", "Capability fit % (see Capability fit &amp; certifications below)"],
     ["Published", "Date the notice was published"],
     ["Closing", "Submission deadline, with a 'days left' countdown"],
     ["Value (£)", "Contract value (numeric) or raw text for ranges/POA"],
     ["Duration", "Contract length or start/end dates"],
     ["Bid Status", "Interactive dropdown: Pending / Bid / No Bid"],
     ["Outcome", "Result dropdown: Awaiting / Won / Lost (drives win rate)"],
     ["Link", "Opens the original notice on the source portal"]],
    [4.5 * cm, 10.5 * cm]))

story.append(Paragraph("Searching, filtering and sorting", h2))
story.append(bullets([
    "<b>Search box</b> — filter by opportunity title or buyer name.",
    "<b>Status filter</b> — show only Pending, Bid, or No Bid opportunities.",
    "<b>Portal filter</b> — show opportunities from a single source portal only.",
    "<b>Sort</b> — order the table by Published date, Closing date, Value, or "
    "Title, using the Asc/Desc toggle to switch direction.",
]))

story.append(Paragraph("Capability fit &amp; certifications", h2))
story.append(Paragraph(
    "Bid Sentinel scores how well each opportunity matches your services. In "
    "<b>Settings &rarr; Capability profile &amp; fit</b>, build a profile of the "
    "services you offer in two ways: <b>add terms manually</b>, and/or <b>upload a "
    "service-overview document</b> (PDF, DOCX or TXT) to <b>generate terms from its "
    "content</b> — then tick the ones to keep (use Select all / Clear to help). "
    "Generation is local keyword extraction by default, or AI when the AI "
    "enhancement is enabled.", body))
story.append(bullets([
    "<b>Fit %</b> on each row is the share of your capability terms the "
    "opportunity mentions. Hover it to see exactly which terms matched, and sort "
    "the table by <b>Fit %</b> to surface the best matches first.",
    "<b>Certifications</b> required/mentioned in a notice (Cyber Essentials Plus, "
    "ISO 27001, CHECK, SC/DV clearance, and more) are detected automatically and "
    "shown as amber chips under the opportunity name.",
    "After changing your profile, click <b>Recompute fit &amp; certs</b> to "
    "re-score existing opportunities (new ones are scored automatically).",
]))
story.append(Paragraph(
    "This is deterministic and explainable: it matches the exact terms in your "
    "lists, so widen coverage by adding terms. Nothing is sent to any external "
    "service.", small))

story.append(Paragraph("AI enhancement (optional)", h2))
story.append(Paragraph(
    "For richer results you can switch on the optional AI enhancement in "
    "<b>Settings &rarr; AI enhancement</b> (administrators only). When on, a "
    "low-cost Claude model produces a concise <b>AI summary</b> (shown at the top "
    "of the expanded view), a reasoned <b>Fit %</b>, and more accurate "
    "certifications, clearances and technical requirements. It requires an API key "
    "to be configured; if none is set the toggle is disabled and Bid Sentinel stays "
    "fully deterministic and offline. After switching it on, click <b>Recompute</b> "
    "to apply it to existing opportunities (new ones are analysed automatically).", body))
story.append(Paragraph(
    "A slow-blinking status dot in the dashboard header shows the AI Assistant at a "
    "glance: <b>green</b> when it is active (enabled and a key configured), "
    "<b>red</b> when it is off.", body))

story.append(Paragraph("Recording decisions and outcomes", h2))
story.append(Paragraph(
    "Each row has two colour-coded dropdowns. The <b>Bid Status</b> column "
    "(Pending / Bid / No Bid) records your decision to pursue an opportunity. The "
    "<b>Outcome</b> column (Awaiting / Won / Lost / <b>No Bid</b> / <b>Disregarded</b>) "
    "records the result once a bid concludes — this feeds the Win rate and Value won "
    "figures. Setting the <b>Bid Status</b> to <b>No Bid</b> automatically defaults the "
    "<b>Outcome</b> to <b>No Bid</b> (you can still change it afterwards). Setting an "
    "outcome to <b>Disregarded</b> marks an opportunity to hide; tick <b>Hide "
    "disregarded</b> in the toolbar to remove them from the view and the analytics. "
    "Both save instantly; if a save fails the dropdown reverts.", body))

story.append(Paragraph("Adding an opportunity manually", h2))
story.append(Paragraph(
    "To track an opportunity Bid Sentinel didn't scrape, click <b>+ Add "
    "opportunity</b> in the toolbar. Enter at least a <b>Title</b> (buyer, value, "
    "dates, duration, URL and description are optional) and click <b>Add "
    "opportunity</b>. It is analysed for certifications, fit and technical "
    "requirements just like a scraped one (paste the details into the Description "
    "to get the most out of this), and is labelled <b>Manual entry</b> — you can "
    "filter to these via the portal filter. A URL is optional; leave it blank and "
    "the Link column simply shows &ldquo;Manual&rdquo;.", body))

story.append(Paragraph("Editing or deleting an opportunity", h2))
story.append(Paragraph(
    "Expand any opportunity (the arrow beside its name) to reveal <b>Edit</b> and "
    "<b>Delete</b> buttons. <b>Edit</b> opens the same form pre-filled so you can "
    "correct any detail — certifications, fit and technical requirements are "
    "re-analysed when you save. <b>Delete</b> removes the opportunity entirely; "
    "use this to clear entries that were scraped in error. Deleting shows an "
    "<b>Undo</b> button in the notice bar — click it to restore the opportunity if "
    "you removed it by mistake (see &ldquo;Undo a deletion&rdquo; below). Both "
    "actions are recorded in the audit trail.", body))

story.append(Paragraph("Bulk-deleting unwanted opportunities", h2))
story.append(Paragraph(
    "Admins see a <b>checkbox</b> on each row. Tick several (or use the header "
    "checkbox to select every row in the current view), then click <b>&#128465; "
    "Delete selected</b> in the toolbar. The chosen opportunities are removed and "
    "— crucially — <b>blocked from being scraped back in</b>: each one's link and "
    "reference are added to a suppression list the scraper checks on every run, so "
    "they will not reappear. Use the search and filters first to narrow the view, "
    "then select-all to clear a whole category quickly. The action is confirmed "
    "before it runs and is recorded in the audit trail.", body))

story.append(Paragraph("Undo a deletion", h2))
story.append(Paragraph(
    "Deleted something by mistake? After any delete — single or bulk — an "
    "<b>&#8629; Undo</b> button appears next to the confirmation message. Click it "
    "to restore the opportunities and, for bulk deletes, automatically lift the "
    "scrape block that was applied. Undo stays available for <b>14 days</b> after a "
    "deletion (comments are not restored). If the same notice has already been "
    "re-scraped in the meantime, that one is skipped.", body))

story.append(Paragraph("Tuning out irrelevant types", h2))
story.append(Paragraph(
    "Some scrapes surface opportunities that are not really Cyber Security — for "
    "example catering, grounds maintenance or printer supplies pulled in by a broad "
    "CPV code. Each row has a <b>&#128277; Tune out</b> button (admins). Click it and "
    "confirm a word or phrase that characterises the type you want gone (a sensible "
    "suggestion is pre-filled). Bid Sentinel then removes the matching opportunities "
    "immediately and suppresses that type on all future scrapes.", body))
story.append(Paragraph(
    "<b>Guardrail:</b> anything Cyber Security related is <b>always kept</b>, no "
    "matter what you tune out — a term only hides opportunities that also have no "
    "cyber-security signal, so you can never accidentally lose a relevant bid. "
    "Manage or remove tune-out terms under <b>Settings &rarr; Tune-out list</b> "
    "(removing a term lets that type back in on the next scrape). Tune-out changes "
    "are recorded in the audit trail.", body))

story.append(Paragraph("Expanding an opportunity", h2))
story.append(Paragraph(
    "Click the <b>&#9656;</b> arrow beside any opportunity name to expand it. The "
    "detail view shows three panes:", body))
story.append(bullets([
    "<b>Certifications, accreditations &amp; security clearances</b> required or "
    "mentioned in the notice (clearances such as SC/DV are listed separately).",
    "<b>Top technical requirements</b> — the most relevant requirement sentences "
    "scraped from the opportunity description.",
    "<b>Comments</b> — add notes for your team; each comment shows its author and "
    "timestamp, and can be deleted by its author or an administrator.",
]))

# --- 4. Exporting ---
story.append(Paragraph("4. Exporting Data", h1))
story.append(Paragraph(
    "Use the toolbar buttons above the table to export the <b>currently filtered "
    "view</b>:", body))
story.append(bullets([
    "<b>Export CSV</b> — downloads a spreadsheet-ready file (opens in Excel / Google Sheets).",
    "<b>Export PDF</b> — downloads a formatted, landscape PDF report of the table.",
]))
story.append(Paragraph(
    "Tip: apply a search term or status filter first to export just that subset.", small))

story.append(Paragraph("Management summary report", h2))
story.append(Paragraph(
    "Admins and analysts see a <b>&#128202; Management PDF</b> button in the toolbar "
    "(and on the Intelligence page). It downloads a concise, board-ready summary of "
    "the pipeline: how many opportunities are <b>tracked, open and being bid on</b>, "
    "the total <b>pipeline value</b> and <b>value won</b>, a breakdown by bid status, "
    "the opportunities currently being bid on, and your <b>capability</b> and "
    "<b>certification / clearance gaps</b>. Pipeline value and the being-bid-on count "
    "exclude anything marked <b>Lost</b>, so the figures match the dashboard. Where "
    "email is configured, an <b>Email summary</b> button sends it to your own "
    "account address.", body))
story.append(Paragraph("Email me this", h2))
story.append(Paragraph(
    "If outgoing email is configured, <b>Email CSV</b> / <b>Email PDF</b> buttons "
    "appear alongside the exports (and an <b>Email me</b> button on the Intelligence "
    "report). These send the report to <b>your own account email address</b> — the "
    "one you signed in with. If you don't see the buttons, email hasn't been set up "
    "on the server (see the Email setup guide).", body))

# --- 5. Settings: Bid Portals ---
story.append(Paragraph("5. Settings — Bid Portals", h1))
story.append(Paragraph(
    "Open <b>Settings</b> from the top-right of the dashboard. The Settings page is "
    "organised into <b>tabs</b> — Users &amp; roles, AI enhancement, Capability "
    "&amp; fit, Bid Portals, Keywords, CPV codes, Tune-out list, Schedule, and Audit "
    "trail (the Users and Audit tabs are visible to administrators only). Click a tab to open that "
    "section; only that section is shown, so there is no long page to scroll.", body))
story.append(Paragraph(
    "On the <b>Bid Portals</b> tab you'll find every portal Bid Sentinel can check. "
    "<b>All portals are selected by default and have an active scraper</b> — switch "
    "any toggle off to exclude it from future scans (admins only). Find a Tender and "
    "Contracts Finder use official data APIs (robust); the others use a best-effort "
    "web scraper that runs every cycle but may need tuning per site (and "
    "credentials for portals that require a login) to return results reliably.", body))
story.append(Paragraph(
    "<b>Adding your own portal:</b> use the <b>Add a portal</b> form at the bottom "
    "of the panel (name, URL, and optional scope). It is registered immediately, "
    "marked <b>Custom</b>, and can be enabled, scheduled, and later given a "
    "scraper. Custom portals can be deleted; built-in ones can only be disabled "
    "with the toggle.", body))
story.append(info_table(
    [["Portal", "Scope"],
     ["Find a Tender Service (FTS)  [API]", "Above-threshold UK public sector procurement"],
     ["Contracts Finder  [API]", "Below-threshold England + wider public sector"],
     ["Sell2Wales", "Welsh public bodies"],
     ["Public Contracts Scotland", "Scottish public sector tenders"],
     ["eTendersNI", "Northern Ireland eProcurement"],
     ["ProContract (Due North)", "Regional councils and local authorities"],
     ["Delta eSourcing", "Various contracting authorities"],
     ["In-Tend", "Public sector localized portal instances"],
     ["Achilles", "Utilities, transport and infrastructure"],
     ["CCS / Government Commercial Agency", "Central government frameworks"],
     ["Digital Marketplace (G-Cloud)", "Cloud and digital services frameworks"]],
    [7.5 * cm, 7.5 * cm]))

# --- 6. Settings: Keywords ---
story.append(Paragraph("6. Settings — Keywords", h1))
story.append(Paragraph(
    "The <b>Keywords</b> panel shows the built-in cyber-security terms Bid "
    "Sentinel always searches (MDR, SOC, Incident Response, Vulnerability "
    "Management, CTI, and more). These cannot be removed.", body))
story.append(Paragraph(
    "Adding your own keywords is <b>optional</b>. Type a term (for example "
    "&ldquo;Penetration Testing&rdquo; or &ldquo;ISO 27001&rdquo;) and click "
    "<b>Add</b>; it is then included in every subsequent scan alongside the "
    "built-in list. Remove a custom keyword at any time with the &times; button.", body))

story.append(Paragraph("CPV codes", h2))
story.append(Paragraph(
    "The <b>CPV codes</b> tab lets you add Common Procurement Vocabulary codes "
    "(the standard EU/UK classification for what a contract is about) to <b>narrow</b> "
    "the scrape. Enter a <b>code</b> (for example 72500000) and an optional "
    "<b>description</b> (for example &ldquo;Computer-related services&rdquo;) and "
    "click <b>Add</b>.", body))
story.append(Paragraph(
    "CPV codes work as a <b>combined filter with your keywords, not an alternative "
    "to them</b>. When one or more CPV codes are <b>active</b>, an opportunity is "
    "only kept if it matches a <b>keyword</b> AND one of your active <b>CPV codes</b> "
    "(in its classification or its text). This removes notices that happen to carry "
    "a matching CPV code but are not actually about cyber security. If no CPV codes "
    "are active, keyword matching alone applies, exactly as before.", body))
story.append(Paragraph(
    "Each code has an <b>Active</b> toggle. Switch a code off to stop it applying to "
    "the scrape without deleting it — handy for temporarily widening or narrowing "
    "coverage. The header shows how many of your codes are currently active. Use "
    "<b>Remove</b> to delete a code permanently.", body))
story.append(Paragraph(
    "<b>CPV matching mode.</b> A toggle at the top of the tab controls how strictly "
    "CPV codes are applied:", body))
story.append(bullets([
    "<b>Lenient (recommended, default)</b> — CPV codes only narrow notices that "
    "actually publish a CPV classification. Notices from sources that don't expose "
    "CPV codes (most HTML portals) are judged on keywords alone, so you don't lose "
    "relevant opportunities just because a portal omits CPV data.",
    "<b>Strict</b> — every opportunity must match one of your CPV codes. Anything "
    "without a matching CPV code — including notices that publish no CPV data at "
    "all — is excluded. Use this only when you want the tightest possible targeting.",
]))
story.append(Paragraph(
    "If you added CPV codes and noticed fewer opportunities than expected, keep the "
    "mode on <b>Lenient</b> (or switch a code off).", small))
story.append(Paragraph(
    "The check-digit suffix is optional — 72500000 and 72500000-0 are treated the "
    "same. Remove a code at any time with the <b>Remove</b> button. Adding or "
    "removing CPV codes is an admin action and is recorded in the audit trail.", body))

story.append(Paragraph("Tune-out list", h2))
story.append(Paragraph(
    "The <b>Tune-out list</b> tab manages the negative terms that hide irrelevant "
    "opportunity types (see &ldquo;Tuning out irrelevant types&rdquo; above). It lists "
    "every tuned-out term; each can be removed to let that type back in on the next "
    "scrape. You can also add a term here directly. A built-in <b>guardrail</b> means "
    "a term only ever hides opportunities that have <b>no</b> cyber-security signal, so "
    "Cyber Security related opportunities are never suppressed.", body))

# --- 7. Settings: Scheduling ---
story.append(Paragraph("7. Settings — Scheduling Scrapes", h1))
story.append(Paragraph(
    "The <b>Scrape Schedule</b> panel lets you run Bid Sentinel automatically on "
    "<b>multiple days and times</b>. To create a schedule:", body))
story.append(ListFlowable([
    ListItem(Paragraph("Select one or more <b>days</b> (Mon–Sun).", body), leftIndent=10),
    ListItem(Paragraph("Add one or more <b>times</b> with the time pickers "
                       "(use &lsquo;+ Add time&rsquo; for more).", body), leftIndent=10),
    ListItem(Paragraph("Click <b>Add to schedule</b>. A slot is created for every "
                       "day &times; time combination.", body), leftIndent=10),
], bulletType="1"))
story.append(Paragraph(
    "Example: choosing Mon &amp; Wed at 09:00 and 17:00 creates four slots. Each "
    "slot can be paused/resumed or deleted individually. The panel shows the "
    "<b>next scheduled run</b> and the timezone in use (default Europe/London). "
    "If no slots are set, Bid Sentinel falls back to a regular interval scan.", body))

# --- 8. The Scraper ---
story.append(Paragraph("8. The Scraper Engine", h1))
story.append(Paragraph(
    "A background worker automatically scans your <b>enabled portals</b> on the "
    "<b>schedule you define</b> (or a regular interval if none is set), keeping "
    "only opportunities that match the built-in and custom keyword list — and, when "
    "you have set CPV codes, that ALSO match one of those codes. Duplicate notices "
    "are detected by URL and reference ID and never inserted twice.", body))
story.append(Paragraph(
    "<b>Only live opportunities are brought in.</b> At scrape time the engine ignores "
    "<b>contract award notices</b> and <b>closed, cancelled or withdrawn</b> tenders, "
    "and will not import an opportunity whose <b>closing date has already passed</b> — "
    "so nothing that is already closed is ever added. (Opportunities that publish no "
    "closing date are kept, since there is no date to judge them on.)", body))
story.append(Paragraph(
    "This filtering applies only when <b>importing new</b> opportunities. An "
    "opportunity that was scraped while it was open <b>stays in your table after its "
    "closing date passes</b> — it is never removed automatically — so your history "
    "and any bids you tracked against it are preserved. Remove old entries yourself "
    "with Delete or the multi-select bulk delete whenever you choose.", body))
story.append(Paragraph("Tracked keywords", h2))
story.append(Paragraph(
    "The built-in list is broad and cyber-specific — e.g. Cyber Security, "
    "Information Security, Managed Detection and Response, MDR, SOC, Security "
    "Operations Centre, Incident Response, Vulnerability Management / Assessment, "
    "Cyber Threat Intelligence (CTI), Penetration Testing, Digital Forensics, "
    "MSSP, SIEM, ISO 27001 and more. Add organisation-specific terms on the "
    "Keywords tab to widen coverage further.", body))
story.append(Paragraph("Running it on demand (admins only)", h2))
story.append(Paragraph(
    "Administrators see a <b>Run Scraper</b> button in the toolbar. Click it to "
    "fetch new opportunities immediately. The status message is a full <b>diagnostic "
    "summary</b>: how many candidates each portal returned, how many were newly "
    "inserted, and a breakdown of why the rest were skipped — already tracked, "
    "closed/expired, CPV-filtered, tuned-out or suppressed — plus whether the CPV filter is running "
    "in lenient or strict mode. If a portal shows <b>0</b> candidates or an error, "
    "that portal returned nothing (some HTML portals need per-site tuning or a "
    "login); if lots were CPV-filtered, switch the CPV mode to Lenient or disable "
    "some CPV codes.", body))

# --- 9. User Administration ---
story.append(Paragraph("9. User Administration (Admins)", h1))
story.append(Paragraph(
    "Open <b>Settings</b> and use the <b>Users &amp; roles</b> panel (visible to "
    "administrators only) to manage accounts with role-based access control:", body))
story.append(bullets([
    "<b>Add a new user</b> — enter a full name, email, password (min 8 "
    "characters) and a role, then click <b>Add user</b>.",
    "<b>Change a role</b> — switch any user between Standard and Admin from the "
    "role dropdown.",
    "<b>Enable / disable</b> — click the status pill to suspend or restore "
    "access without deleting the account.",
    "<b>Reset password</b> — set a new password for any user inline.",
    "<b>Delete</b> — remove an account. You cannot delete or change the role of "
    "your own account (a safeguard against locking yourself out).",
]))
story.append(Paragraph("Roles", h2))
story.append(info_table(
    [["Role", "Capabilities"],
     ["Admin", "Everything: manage users, portals, keywords, schedule, run scraper"],
     ["Analyst", "Read-only, plus the Intelligence tab and its PDF report"],
     ["Standard", "View tenders, search/filter/sort, set bid status & outcome, export"]],
    [4.5 * cm, 10.5 * cm]))

story.append(Paragraph("Audit trail", h2))
story.append(Paragraph(
    "The <b>Audit trail</b> panel in Settings (administrators only) records "
    "application activity — logins and every change — with the actor, action, "
    "timestamp, result, and IP address. Use <b>Refresh</b> to load the latest "
    "entries; the most recent appear first.", body))

# --- 10. Analyst Intelligence ---
story.append(Paragraph("10. Analyst Intelligence", h1))
story.append(Paragraph(
    "Users with the <b>Analyst</b> (or Admin) role see an <b>Intelligence</b> link "
    "in the dashboard header. The Intelligence tab mines the opportunity "
    "assessments to surface gaps and sales angles:", body))
story.append(bullets([
    "<b>Capability gaps</b> — in-demand services appearing across opportunities "
    "that your capability profile doesn't cover, ranked by frequency and value.",
    "<b>Certification &amp; clearance gaps</b> — how often each is required versus "
    "whether you hold it (from the <b>Accreditations you hold</b> list in Settings "
    "&rarr; Capability &amp; fit). Flags certs that block you.",
    "<b>Buyer intelligence</b> — a profile per client organisation (volume, value, "
    "average fit, win/loss, most-requested services) and which are prime targets.",
    "<b>Win/loss</b> and <b>demand trends</b> — where you win or lose, and how "
    "demand is moving over time.",
    "<b>Sales angles</b> — a concise, data-driven pitch for each target buyer; "
    "tick <b>AI angles</b> for an AI-written version (requires the AI enhancement).",
]))
story.append(Paragraph(
    "Click <b>Export PDF report</b> to download a branded, printable Intelligence "
    "Report covering all of the above for sharing with the wider team.", body))

# --- 11. Troubleshooting ---
story.append(Paragraph("11. Troubleshooting", h1))
story.append(info_table(
    [["Symptom", "Resolution"],
     ["Cannot reach dashboard", "Check 'docker compose ps'; ensure containers are 'Up'."],
     ["Login fails", "Verify ADMIN_EMAIL/PASSWORD in .env; data persists across restarts."],
     ["Table is empty", "Click 'Run Scraper' (admin) or wait for the scheduled run."],
     ["Reset everything", "Run 'docker compose down -v' then 'up --build -d'."]],
    [5.5 * cm, 9.5 * cm]))

story.append(Spacer(1, 0.6 * cm))
story.append(HRFlowable(width="100%", thickness=1, color=HexColor("#cbd5e1"), spaceAfter=6))
story.append(Paragraph(
    "Bid Sentinel — UK Cyber Security Tender Platform. User Guide. Always respect "
    "the terms of use and rate limits of the source portals when scraping.", small))


def _footer(canvas, doc):
    canvas.saveState()
    canvas.setFont("Helvetica", 8)
    canvas.setFillColor(GREY)
    canvas.drawString(2 * cm, 1.1 * cm, "Bid Sentinel")
    canvas.drawRightString(A4[0] - 2 * cm, 1.1 * cm, f"Page {doc.page}")
    canvas.restoreState()


_DOCS_DIR = os.path.join(os.path.dirname(__file__), "docs")
os.makedirs(_DOCS_DIR, exist_ok=True)

doc = SimpleDocTemplate(
    os.path.join(_DOCS_DIR, "USER_GUIDE.pdf"), pagesize=A4,
    leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.8 * cm, bottomMargin=1.8 * cm,
    title="Bid Sentinel - User Guide", author="Bid Sentinel",
)
doc.build(story, onFirstPage=_footer, onLaterPages=_footer)
print("USER_GUIDE.pdf generated.")