Skip to content

Commit b637225

Browse files
authored
feat: implement structured extraction checkpoints B1 and B2 (#912)
* feat: implement structured extraction checkpoints B1 and B2 Signed-off-by: Abhijeet Saharan <abhijeetsaharan2236@gmail.com> * docs: improve formatting Signed-off-by: Abhijeet <abhijeetsaharan2236@gmail.com> * fix: improve normalization of required string fields Signed-off-by: Abhijeet <abhijeetsaharan2236@gmail.com> * docs: add docstrings Signed-off-by: Abhijeet Saharan <abhijeetsaharan2236@gmail.com> * fix: validate normalized string field values correctly Signed-off-by: Abhijeet Saharan <abhijeetsaharan2236@gmail.com> --------- Signed-off-by: Abhijeet Saharan <abhijeetsaharan2236@gmail.com> Signed-off-by: Abhijeet <abhijeetsaharan2236@gmail.com>
1 parent e93ce92 commit b637225

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from dataclasses import dataclass, field
2+
from typing import Dict, List
3+
4+
SUMMARY_MAX_LENGTH = 500
5+
6+
7+
@dataclass
8+
class CheatsheetRecord:
9+
"""Structured representation of an OWASP cheatsheet record."""
10+
11+
source: str = field(default="owasp_cheatsheets", init=False)
12+
source_id: str
13+
title: str
14+
hyperlink: str
15+
summary: str
16+
headings: List[str]
17+
raw_markdown_path: str
18+
category_hints: List[str] = field(default_factory=list)
19+
metadata: Dict[str, str] = field(default_factory=dict)
20+
21+
def __post_init__(self):
22+
"""Normalize and validate CheatsheetRecord fields."""
23+
24+
required_str_fields = {
25+
"source_id": self.source_id,
26+
"title": self.title,
27+
"hyperlink": self.hyperlink,
28+
"summary": self.summary,
29+
"raw_markdown_path": self.raw_markdown_path,
30+
}
31+
32+
# Summary-specific normalization
33+
self.summary = self.summary.strip()[:SUMMARY_MAX_LENGTH]
34+
35+
# Normalize fields which require string values.
36+
for field_name, value in required_str_fields.items():
37+
if isinstance(value, str):
38+
setattr(self, field_name, value.strip())
39+
40+
list_str_fields = {
41+
"headings": self.headings,
42+
"category_hints": self.category_hints,
43+
}
44+
45+
# Validate fields which require string values.
46+
for field_name in required_str_fields:
47+
value = getattr(self, field_name)
48+
49+
if not isinstance(value, str) or not value:
50+
raise ValueError(
51+
f"CheatsheetRecord: field '{field_name}' "
52+
f"must be a non-empty string, got {value!r}"
53+
)
54+
55+
# Validate fields which require list[str] values.
56+
for field_name, value in list_str_fields.items():
57+
if not isinstance(value, list):
58+
raise ValueError(
59+
f"CheatsheetRecord: field '{field_name}' "
60+
f"must be a list[str], got {type(value)!r}"
61+
)
62+
63+
for item in value:
64+
if not isinstance(item, str):
65+
raise ValueError(
66+
f"CheatsheetRecord: value of '{field_name}' "
67+
f"must be a string, got {item!r}"
68+
)
69+
70+
# Validate input for metadata.
71+
if not isinstance(self.metadata, dict):
72+
raise ValueError(
73+
"CheatsheetRecord: field 'metadata' must be a dict[str, str]"
74+
)
75+
76+
for key, value in self.metadata.items():
77+
if not isinstance(key, str) or not isinstance(value, str):
78+
raise ValueError(
79+
"CheatsheetRecord: metadata keys and values must be strings, "
80+
f"got {key!r}: {value!r}"
81+
)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import os
2+
import re
3+
4+
from application.defs.cheatsheet_defs import CheatsheetRecord
5+
6+
PARSER_VERSION = "v1"
7+
FALLBACK_USED = "false"
8+
9+
CANONICAL_BASE_URL = "https://cheatsheetseries.owasp.org/cheatsheets/"
10+
11+
_TITLE_RE = re.compile(r"^#\s+(?P<title>.+)$", re.MULTILINE)
12+
_HEADING_RE = re.compile(r"^##\s+(?P<heading>.+)$", re.MULTILINE)
13+
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+.+$", re.MULTILINE)
14+
15+
16+
def _derive_source_id(source_path: str) -> str:
17+
"""Derive the cheatsheet source ID from the markdown file path."""
18+
19+
basename = os.path.basename(source_path)
20+
source_id, _ = os.path.splitext(basename)
21+
22+
return source_id
23+
24+
25+
def _derive_hyperlink(source_path: str) -> str:
26+
"""Generate the canonical OWASP cheatsheet hyperlink."""
27+
28+
source_id = _derive_source_id(source_path)
29+
30+
return f"{CANONICAL_BASE_URL}{source_id}.html"
31+
32+
33+
def _extract_body_after_heading(markdown: str, heading_match: re.Match) -> str:
34+
"""Extract body content until the next markdown heading."""
35+
36+
start = heading_match.end()
37+
next_heading = _ANY_HEADING_RE.search(markdown, start)
38+
end = next_heading.start() if next_heading else len(markdown)
39+
40+
return markdown[start:end].strip()
41+
42+
43+
def _extract_summary(markdown: str) -> str:
44+
"""Extract a summary section from cheatsheet markdown."""
45+
46+
all_heading_matches = list(_ANY_HEADING_RE.finditer(markdown))
47+
48+
for match in all_heading_matches:
49+
heading_text = match.group().lstrip("#").strip()
50+
51+
if heading_text.lower() == "introduction":
52+
body = _extract_body_after_heading(markdown, match)
53+
54+
if body:
55+
return body
56+
57+
break
58+
59+
for match in all_heading_matches:
60+
body = _extract_body_after_heading(markdown, match)
61+
62+
if body:
63+
return body
64+
65+
raise ValueError("_extract_summary: no summary could be extracted from markdown.")
66+
67+
68+
def extract_cheatsheet_record(
69+
markdown: str,
70+
source_path: str,
71+
) -> CheatsheetRecord:
72+
"""Extract a structured CheatsheetRecord from markdown content."""
73+
74+
title_match = _TITLE_RE.search(markdown)
75+
title = title_match.group("title").strip()
76+
77+
headings = [m.group("heading").strip() for m in _HEADING_RE.finditer(markdown)]
78+
79+
summary = _extract_summary(markdown)
80+
81+
source_id = _derive_source_id(source_path)
82+
hyperlink = _derive_hyperlink(source_path)
83+
84+
return CheatsheetRecord(
85+
source_id=source_id,
86+
title=title,
87+
hyperlink=hyperlink,
88+
summary=summary,
89+
headings=headings,
90+
raw_markdown_path=source_path,
91+
category_hints=[],
92+
metadata={
93+
"parser_version": PARSER_VERSION,
94+
"fallback_used": FALLBACK_USED,
95+
},
96+
)

0 commit comments

Comments
 (0)