skillware 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {skillware-0.2.3/skillware.egg-info → skillware-0.2.5}/PKG-INFO +5 -2
  2. {skillware-0.2.3 → skillware-0.2.5}/README.md +3 -1
  3. {skillware-0.2.3 → skillware-0.2.5}/pyproject.toml +2 -1
  4. skillware-0.2.5/skills/compliance/mica_module/__init__.py +3 -0
  5. skillware-0.2.5/skills/compliance/mica_module/skill.py +229 -0
  6. skillware-0.2.5/skills/compliance/tos_evaluator/__init__.py +3 -0
  7. skillware-0.2.5/skills/compliance/tos_evaluator/skill.py +587 -0
  8. skillware-0.2.5/skills/compliance/tos_evaluator/test_skill.py +30 -0
  9. {skillware-0.2.3 → skillware-0.2.5/skillware.egg-info}/PKG-INFO +5 -2
  10. {skillware-0.2.3 → skillware-0.2.5}/skillware.egg-info/SOURCES.txt +7 -1
  11. {skillware-0.2.3 → skillware-0.2.5}/skillware.egg-info/requires.txt +1 -0
  12. skillware-0.2.5/tests/test_mica_module.py +66 -0
  13. {skillware-0.2.3 → skillware-0.2.5}/LICENSE +0 -0
  14. {skillware-0.2.3 → skillware-0.2.5}/setup.cfg +0 -0
  15. {skillware-0.2.3 → skillware-0.2.5}/skills/compliance/pii_masker/__init__.py +0 -0
  16. {skillware-0.2.3 → skillware-0.2.5}/skills/compliance/pii_masker/skill.py +0 -0
  17. {skillware-0.2.3 → skillware-0.2.5}/skills/data_engineering/synthetic_generator/__init__.py +0 -0
  18. {skillware-0.2.3 → skillware-0.2.5}/skills/data_engineering/synthetic_generator/skill.py +0 -0
  19. {skillware-0.2.3 → skillware-0.2.5}/skills/finance/wallet_screening/__init__.py +0 -0
  20. {skillware-0.2.3 → skillware-0.2.5}/skills/finance/wallet_screening/maintenance/normalization_tool.py +0 -0
  21. {skillware-0.2.3 → skillware-0.2.5}/skills/finance/wallet_screening/maintenance/normalize_uniswap_trm.py +0 -0
  22. {skillware-0.2.3 → skillware-0.2.5}/skills/finance/wallet_screening/skill.py +0 -0
  23. {skillware-0.2.3 → skillware-0.2.5}/skills/office/pdf_form_filler/skill.py +0 -0
  24. {skillware-0.2.3 → skillware-0.2.5}/skills/office/pdf_form_filler/utils.py +0 -0
  25. {skillware-0.2.3 → skillware-0.2.5}/skills/optimization/prompt_rewriter/__init__.py +0 -0
  26. {skillware-0.2.3 → skillware-0.2.5}/skills/optimization/prompt_rewriter/skill.py +0 -0
  27. {skillware-0.2.3 → skillware-0.2.5}/skillware/__init__.py +0 -0
  28. {skillware-0.2.3 → skillware-0.2.5}/skillware/core/__init__.py +0 -0
  29. {skillware-0.2.3 → skillware-0.2.5}/skillware/core/base_skill.py +0 -0
  30. {skillware-0.2.3 → skillware-0.2.5}/skillware/core/env.py +0 -0
  31. {skillware-0.2.3 → skillware-0.2.5}/skillware/core/loader.py +0 -0
  32. {skillware-0.2.3 → skillware-0.2.5}/skillware.egg-info/dependency_links.txt +0 -0
  33. {skillware-0.2.3 → skillware-0.2.5}/skillware.egg-info/top_level.txt +0 -0
  34. {skillware-0.2.3 → skillware-0.2.5}/tests/test_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillware
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: A framework for modular, self-contained AI skills.
5
5
  Author-email: ARPA Hellenic Logic Systems <skillware-os@arpacorp.net>
6
6
  License: MIT License
@@ -38,6 +38,7 @@ Requires-Dist: pyyaml
38
38
  Requires-Dist: anthropic
39
39
  Requires-Dist: google-generativeai
40
40
  Requires-Dist: pymupdf
41
+ Requires-Dist: beautifulsoup4
41
42
  Provides-Extra: dev
42
43
  Requires-Dist: pytest; extra == "dev"
43
44
  Requires-Dist: pytest-mock; extra == "dev"
@@ -102,7 +103,8 @@ Skillware/
102
103
  │ └── skill_name/ # The Skill bundle
103
104
  │ ├── manifest.yaml # Definition, schema, and constitution
104
105
  │ ├── skill.py # Executable Python logic
105
- └── instructions.md # Cognitive map for the LLM
106
+ ├── instructions.md # Cognitive map for the LLM
107
+ │ └── test_skill.py # Unit tests & schema validation
106
108
  ├── skillware/ # Core Framework Package
107
109
  │ └── core/
108
110
  │ ├── base_skill.py # Abstract Base Class for skills
@@ -172,6 +174,7 @@ print(response.text)
172
174
  ## Documentation
173
175
 
174
176
  * **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
177
+ * **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
175
178
  * **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
176
179
  * **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
177
180
  * **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
@@ -55,7 +55,8 @@ Skillware/
55
55
  │ └── skill_name/ # The Skill bundle
56
56
  │ ├── manifest.yaml # Definition, schema, and constitution
57
57
  │ ├── skill.py # Executable Python logic
58
- └── instructions.md # Cognitive map for the LLM
58
+ ├── instructions.md # Cognitive map for the LLM
59
+ │ └── test_skill.py # Unit tests & schema validation
59
60
  ├── skillware/ # Core Framework Package
60
61
  │ └── core/
61
62
  │ ├── base_skill.py # Abstract Base Class for skills
@@ -125,6 +126,7 @@ print(response.text)
125
126
  ## Documentation
126
127
 
127
128
  * **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
129
+ * **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
128
130
  * **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
129
131
  * **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
130
132
  * **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillware"
7
- version = "0.2.3"
7
+ version = "0.2.5"
8
8
  description = "A framework for modular, self-contained AI skills."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -23,6 +23,7 @@ dependencies = [
23
23
  "anthropic",
24
24
  "google-generativeai",
25
25
  "pymupdf",
26
+ "beautifulsoup4",
26
27
  ]
27
28
  requires-python = ">=3.10"
28
29
 
@@ -0,0 +1,3 @@
1
+ from .skill import MiCAModuleSkill
2
+
3
+ __all__ = ["MiCAModuleSkill"]
@@ -0,0 +1,229 @@
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, List
4
+ import google.generativeai as genai
5
+ from skillware.core.base_skill import BaseSkill
6
+
7
+
8
+ class MiCAModuleSkill(BaseSkill):
9
+ """
10
+ Acts as a highly specialized, localized RAG and policy enforcement engine for MiCA.
11
+ """
12
+
13
+ @property
14
+ def manifest(self) -> Dict[str, Any]:
15
+ return {"name": "compliance/mica_module", "version": "0.1.0"}
16
+
17
+ _corpus_cache: List[Dict[str, Any]] = None
18
+
19
+ def __init__(self, config: Dict[str, Any] = None):
20
+ super().__init__(config)
21
+ self._ensure_corpus_loaded()
22
+
23
+ def _ensure_corpus_loaded(self):
24
+ """Lazy loader for the MiCA JSON corpus."""
25
+ if MiCAModuleSkill._corpus_cache is not None:
26
+ return
27
+
28
+ corpus_path = os.path.join(os.path.dirname(__file__), "mica_corpus.json")
29
+ try:
30
+ with open(corpus_path, "r", encoding="utf-8") as f:
31
+ MiCAModuleSkill._corpus_cache = json.load(f)
32
+ except Exception as e:
33
+ print(f"Error loading MiCA corpus: {e}")
34
+ MiCAModuleSkill._corpus_cache = []
35
+
36
+ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
37
+ user_prompt = params.get("user_prompt", "")
38
+ run_evaluator = params.get("run_evaluator", False)
39
+ evaluator_model = params.get("evaluator_model", "gemini-2.5-flash-lite")
40
+
41
+ # Use the cached corpus
42
+ mica_data = MiCAModuleSkill._corpus_cache
43
+
44
+ # 2. Extract Intent and Route to matched sections
45
+ relevant_chunks = self._route_and_fetch(user_prompt, mica_data)
46
+
47
+ # Format the retrieved sections list
48
+ retrieved_sections = []
49
+ context_text = ""
50
+ for chunk in relevant_chunks:
51
+ title_info = chunk.get("title_num", "")
52
+ if chunk.get("title_name"):
53
+ title_info += f": {chunk.get('title_name')}"
54
+
55
+ art_info = chunk.get("article_num", "")
56
+ if chunk.get("article_title"):
57
+ art_info += f": {chunk.get('article_title')}"
58
+
59
+ sec_name = f"{title_info} | {art_info}"
60
+ retrieved_sections.append(sec_name)
61
+ context_text += f"\n--- {sec_name} ---\n{chunk.get('content', '')}\n"
62
+
63
+ # 3. Default Policy Status if no evaluator runs
64
+ policy_status = "CAUTION"
65
+ gemini_feedback = {
66
+ "grade": "N/A",
67
+ "holes_found": (
68
+ "Evaluator disabled. Review MiCA context manually for regulatory holes."
69
+ ),
70
+ "suggestion": "Follow the integrated MiCA chunks exactly.",
71
+ }
72
+
73
+ if not retrieved_sections:
74
+ final_context = "No specific MiCA articles matched the user query."
75
+ else:
76
+ final_context = (
77
+ "Output your final answer seamlessly integrating and adhering to "
78
+ f"these MiCA rules:\n{context_text}"
79
+ )
80
+
81
+ # 4. Optional Evaluator Node execution
82
+ if run_evaluator and relevant_chunks:
83
+ eval_result = self._run_evaluator(
84
+ user_prompt, context_text, evaluator_model
85
+ )
86
+ policy_status = eval_result.get("policy_status", policy_status)
87
+ gemini_feedback = eval_result.get(
88
+ "gemini_evaluator_feedback", gemini_feedback
89
+ )
90
+ final_context = eval_result.get("final_context_for_agent", final_context)
91
+
92
+ return {
93
+ "retrieved_sections": list(set(retrieved_sections)),
94
+ "policy_status": policy_status,
95
+ "gemini_evaluator_feedback": gemini_feedback,
96
+ "final_context_for_agent": final_context,
97
+ }
98
+
99
+ def _route_and_fetch(
100
+ self, prompt: str, corpus: List[Dict[str, Any]]
101
+ ) -> List[Dict[str, Any]]:
102
+ # Lightweight keyword overlap router to prevent huge context bloat.
103
+ prompt_lower = prompt.lower()
104
+
105
+ # Normalize common spelling variations (US to UK for the European regulation)
106
+ replacements = {
107
+ "authorization": "authorisation",
108
+ "authorize": "authorise",
109
+ "organization": "organisation",
110
+ "crypto asset": "crypto-asset",
111
+ "stablecoin": "asset-referenced token", # High-level intent mapping
112
+ }
113
+ normalized_prompt = prompt_lower
114
+ for us, uk in replacements.items():
115
+ normalized_prompt = normalized_prompt.replace(us, uk)
116
+
117
+ # We look for significant words to increase collision hits
118
+ prompt_words = [
119
+ w.lower()
120
+ for w in normalized_prompt.replace("?", "")
121
+ .replace(".", "")
122
+ .replace(",", "")
123
+ .split()
124
+ if len(w) > 3
125
+ ]
126
+
127
+ scored_matches = []
128
+ # We look for significant words to increase collision hits
129
+ prompt_words = [
130
+ w.lower()
131
+ for w in normalized_prompt.replace("?", "")
132
+ .replace(".", "")
133
+ .replace(",", "")
134
+ .split()
135
+ if len(w) > 3
136
+ ]
137
+
138
+ for article in corpus:
139
+ score = 0
140
+ keywords = [k.lower() for k in article.get("keywords", [])]
141
+ art_num = article.get("article_num", "").lower()
142
+ art_title = article.get("article_title", "").lower()
143
+
144
+ # Match 1: Specific article mention (Highest Priority)
145
+ if art_num and f"article {art_num}" in normalized_prompt:
146
+ score += 100
147
+
148
+ # Match 2: Exact keyword match in prompt
149
+ for k in keywords:
150
+ if k in normalized_prompt:
151
+ score += 20
152
+
153
+ # Match 3: Article title collision
154
+ if any(w in art_title for w in prompt_words):
155
+ score += 10
156
+
157
+ # Match 4: Significant word collision with keywords (Normalized by length)
158
+ collision_count = 0
159
+ for w in prompt_words:
160
+ for k in keywords:
161
+ if w in k:
162
+ # Favor specificity: longer word matches are more significant
163
+ collision_count += len(w) / max(len(k), 1)
164
+ score += collision_count * 5
165
+
166
+ if score > 0:
167
+ scored_matches.append((score, article))
168
+
169
+ # Sort by score descending
170
+ scored_matches.sort(key=lambda x: x[0], reverse=True)
171
+
172
+ # Deduplicate and limit
173
+ unique_matches = []
174
+ seen = set()
175
+ for score, m in scored_matches:
176
+ a_id = f"{m.get('title_num', '')}_{m.get('article_num', '')}"
177
+ if a_id not in seen:
178
+ unique_matches.append(m)
179
+ seen.add(a_id)
180
+
181
+ # Return top 10 most relevant hits to maximize production depth
182
+ return unique_matches[:10]
183
+
184
+ def _run_evaluator(
185
+ self, prompt: str, context: str, model_name: str
186
+ ) -> Dict[str, Any]:
187
+ prompt_payload = f"""
188
+ You are a MiCA Regulation Evaluator.
189
+ User Query: {prompt}
190
+ MiCA Rule Context from RAG: {context}
191
+
192
+ Draft a response silently to see what an AI would say based on the user query.
193
+ Then, evaluate that draft against the MiCA rules to see if it violates
194
+ anything or misses vital compliance disclosures (like publishing a
195
+ White Paper, authorization required, etc).
196
+ Return exactly a JSON summarizing the grade and issues found.
197
+ Schema:
198
+ {{
199
+ "policy_status": "APPROVED|CAUTION|HIGH_RISK_DETECTED",
200
+ "gemini_evaluator_feedback": {{
201
+ "grade": "<Letter Grade (A to F)>",
202
+ "holes_found": "<Issues the drafted response missed>",
203
+ "suggestion": "<How the agent should fix the holes in its final answer>"
204
+ }},
205
+ "final_context_for_agent": "Output instructions for the agent embedding your suggestion and the context."
206
+ }}
207
+ """
208
+
209
+ try:
210
+ model = genai.GenerativeModel(model_name)
211
+ resp = model.generate_content(
212
+ prompt_payload,
213
+ generation_config=genai.GenerationConfig(
214
+ response_mime_type="application/json", temperature=0.0
215
+ ),
216
+ )
217
+ return json.loads(resp.text)
218
+ except Exception as e:
219
+ return {
220
+ "policy_status": "CAUTION",
221
+ "gemini_evaluator_feedback": {
222
+ "grade": "N/A",
223
+ "holes_found": f"Evaluator API failed or rate-limited: {str(e)}",
224
+ "suggestion": "Proceed manually integrating the extracted logic.",
225
+ },
226
+ "final_context_for_agent": (
227
+ f"Output your final answer seamlessly integrating and adhering to these MiCA rules:\n{context}"
228
+ ),
229
+ }
@@ -0,0 +1,3 @@
1
+ from .skill import TOSEvaluatorSkill
2
+
3
+ __all__ = ["TOSEvaluatorSkill"]
@@ -0,0 +1,587 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import Any, Dict, List, Optional
5
+ from urllib.parse import urljoin, urlparse
6
+ from urllib.robotparser import RobotFileParser
7
+
8
+ import requests
9
+ import yaml
10
+ from bs4 import BeautifulSoup
11
+ from skillware.core.base_skill import BaseSkill
12
+
13
+ try:
14
+ import google.generativeai as genai
15
+ except ImportError: # pragma: no cover - dependency is optional at runtime
16
+ genai = None
17
+
18
+
19
+ class TOSEvaluatorSkill(BaseSkill):
20
+ """
21
+ Evaluates whether an automated website action appears permissible based on
22
+ robots.txt and discovered legal policy pages.
23
+ """
24
+
25
+ WELL_KNOWN_POLICY_PATHS = [
26
+ "/terms",
27
+ "/terms-of-service",
28
+ "/terms-of-use",
29
+ "/tos",
30
+ "/legal/terms",
31
+ "/legal",
32
+ "/conditions",
33
+ "/policies/terms",
34
+ "/acceptable-use",
35
+ "/aup",
36
+ "/developer-terms",
37
+ "/api-terms",
38
+ ]
39
+
40
+ POLICY_KEYWORDS = {
41
+ "terms": 8,
42
+ "terms of service": 12,
43
+ "terms of use": 12,
44
+ "tos": 6,
45
+ "legal": 5,
46
+ "conditions": 4,
47
+ "user agreement": 8,
48
+ "acceptable use": 10,
49
+ "developer terms": 8,
50
+ "api terms": 10,
51
+ "api policy": 9,
52
+ "robots": 3,
53
+ }
54
+
55
+ ACTION_PATTERNS = {
56
+ "scrape": [r"\bscrap", r"\bextract", r"\bharvest", r"\bcollect data\b"],
57
+ "crawl": [r"\bcrawl", r"\bspider", r"\bbot\b", r"\bautomated access\b"],
58
+ "index": [r"\bindex", r"\bsearch engine", r"\barchive", r"\bmirror"],
59
+ "api_use": [r"\bapi\b", r"\bdeveloper\b", r"\bintegration\b"],
60
+ "monitor": [r"\bmonitor", r"\bwatch", r"\btrack", r"\bcheck periodically\b"],
61
+ "download": [r"\bdownload", r"\bexport", r"\bbulk\b"],
62
+ "automated_access": [r"\bautomation\b", r"\bscript", r"\bprogrammatic\b"],
63
+ }
64
+
65
+ CLAUSE_PATTERNS = [
66
+ {
67
+ "label": "hard_block",
68
+ "severity": "high",
69
+ "weight": -45,
70
+ "patterns": [
71
+ r"may not scrape",
72
+ r"must not scrape",
73
+ r"no scraping",
74
+ r"no crawlers",
75
+ r"no robots",
76
+ r"no automated means",
77
+ r"prohibited automated access",
78
+ r"automated means.*prohibited",
79
+ r"harvest.*prohibited",
80
+ r"crawl.*prohibited",
81
+ ],
82
+ },
83
+ {
84
+ "label": "soft_caution",
85
+ "severity": "medium",
86
+ "weight": -20,
87
+ "patterns": [
88
+ r"prior written consent",
89
+ r"without our permission",
90
+ r"reasonable rate",
91
+ r"rate limit",
92
+ r"commercial use.*restricted",
93
+ r"access.*subject to",
94
+ r"must comply with.*api",
95
+ r"use the api",
96
+ ],
97
+ },
98
+ {
99
+ "label": "permission",
100
+ "severity": "low",
101
+ "weight": 18,
102
+ "patterns": [
103
+ r"permitted to access",
104
+ r"you may use.*api",
105
+ r"public api",
106
+ r"developers may access",
107
+ r"search engines may crawl",
108
+ ],
109
+ },
110
+ ]
111
+
112
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
113
+ super().__init__(config)
114
+ self.session = requests.Session()
115
+ self.session.headers.update(
116
+ {
117
+ "User-Agent": "Skillware-TOS-Evaluator/0.1 (+https://github.com/ARPAHLS/skillware)"
118
+ }
119
+ )
120
+
121
+ @property
122
+ def manifest(self) -> Dict[str, Any]:
123
+ manifest_path = os.path.join(os.path.dirname(__file__), "manifest.yaml")
124
+ if os.path.exists(manifest_path):
125
+ with open(manifest_path, "r", encoding="utf-8") as f:
126
+ return yaml.safe_load(f)
127
+ return {}
128
+
129
+ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
130
+ normalized = self._normalize_input(params)
131
+ if "error" in normalized:
132
+ return normalized
133
+
134
+ robots_assessment = self._evaluate_robots(
135
+ normalized["origin"], normalized["target_url"], normalized["user_agent"]
136
+ )
137
+ policy_candidates = self._discover_policy_pages(
138
+ normalized["origin"], robots_assessment, normalized["max_terms_pages"]
139
+ )
140
+ tos_assessment = self._evaluate_policy_pages(
141
+ normalized, policy_candidates, normalized["max_terms_pages"]
142
+ )
143
+
144
+ llm_assessment = None
145
+ if self._should_use_llm(normalized, robots_assessment, tos_assessment):
146
+ llm_assessment = self._run_llm_evaluator(normalized, tos_assessment)
147
+
148
+ return self._build_final_result(
149
+ normalized,
150
+ robots_assessment,
151
+ tos_assessment,
152
+ llm_assessment,
153
+ policy_candidates,
154
+ )
155
+
156
+ def _normalize_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
157
+ target_url = params.get("target_url", "").strip()
158
+ intended_action = params.get("intended_action", "").strip()
159
+ if not target_url:
160
+ return {"error": "target_url is required."}
161
+ if not intended_action:
162
+ return {"error": "intended_action is required."}
163
+
164
+ parsed = urlparse(target_url)
165
+ if not parsed.scheme or not parsed.netloc:
166
+ return {"error": "target_url must be a fully qualified URL."}
167
+
168
+ origin = f"{parsed.scheme}://{parsed.netloc}"
169
+ action_type = self._classify_action(intended_action)
170
+ user_agent = params.get("user_agent", self.session.headers["User-Agent"])
171
+
172
+ return {
173
+ "target_url": target_url,
174
+ "intended_action": intended_action,
175
+ "action_type": action_type,
176
+ "origin": origin,
177
+ "path": parsed.path or "/",
178
+ "user_agent": user_agent,
179
+ "fetch_mode": params.get("fetch_mode", "lightweight"),
180
+ "use_llm_evaluator": bool(params.get("use_llm_evaluator", False)),
181
+ "llm_provider": params.get("llm_provider", "gemini"),
182
+ "llm_model": params.get("llm_model", "gemini-2.5-flash-lite"),
183
+ "assume_authenticated_session": bool(
184
+ params.get("assume_authenticated_session", False)
185
+ ),
186
+ "max_terms_pages": max(1, min(int(params.get("max_terms_pages", 5)), 10)),
187
+ }
188
+
189
+ def _classify_action(self, intended_action: str) -> str:
190
+ lowered = intended_action.lower()
191
+ for action, patterns in self.ACTION_PATTERNS.items():
192
+ if any(re.search(pattern, lowered) for pattern in patterns):
193
+ return action
194
+ return "automated_access"
195
+
196
+ def _evaluate_robots(
197
+ self, origin: str, target_url: str, user_agent: str
198
+ ) -> Dict[str, Any]:
199
+ robots_url = f"{origin}/robots.txt"
200
+ assessment = {
201
+ "status": "unavailable",
202
+ "robots_url": robots_url,
203
+ "can_fetch": None,
204
+ "matched_rule": "unknown",
205
+ "crawl_delay": None,
206
+ "request_rate": None,
207
+ "sitemaps": [],
208
+ "reason": "robots.txt could not be retrieved.",
209
+ }
210
+
211
+ try:
212
+ response = self.session.get(robots_url, timeout=10)
213
+ if response.status_code >= 400:
214
+ assessment["reason"] = f"robots.txt returned HTTP {response.status_code}."
215
+ return assessment
216
+
217
+ parser = RobotFileParser()
218
+ parser.set_url(robots_url)
219
+ parser.parse(response.text.splitlines())
220
+
221
+ can_fetch = parser.can_fetch(user_agent, target_url)
222
+ assessment["status"] = "parsed"
223
+ assessment["can_fetch"] = can_fetch
224
+ assessment["matched_rule"] = "allowed" if can_fetch else "disallowed"
225
+ assessment["crawl_delay"] = parser.crawl_delay(user_agent)
226
+ assessment["request_rate"] = parser.request_rate(user_agent)
227
+ assessment["sitemaps"] = parser.site_maps() or []
228
+ assessment["reason"] = (
229
+ "robots.txt allows the target path."
230
+ if can_fetch
231
+ else "robots.txt disallows the target path for the supplied user-agent."
232
+ )
233
+ return assessment
234
+ except requests.RequestException as exc:
235
+ assessment["reason"] = f"robots.txt request failed: {str(exc)}"
236
+ return assessment
237
+
238
+ def _discover_policy_pages(
239
+ self, origin: str, robots_assessment: Dict[str, Any], max_terms_pages: int
240
+ ) -> Dict[str, List[Dict[str, Any]]]:
241
+ candidates: Dict[str, Dict[str, Any]] = {}
242
+
243
+ for path in self.WELL_KNOWN_POLICY_PATHS:
244
+ url = urljoin(origin, path)
245
+ score = 50 if "terms" in path or "acceptable" in path else 35
246
+ candidates[url] = {
247
+ "url": url,
248
+ "score": score,
249
+ "source": "well_known_path",
250
+ "label": path.strip("/") or "legal",
251
+ }
252
+
253
+ html_pages = [origin]
254
+ for sitemap_url in robots_assessment.get("sitemaps", [])[:2]:
255
+ html_pages.append(sitemap_url)
256
+
257
+ for url in html_pages:
258
+ discovered = self._extract_candidate_links(url, origin)
259
+ for item in discovered:
260
+ existing = candidates.get(item["url"])
261
+ if existing:
262
+ existing["score"] = max(existing["score"], item["score"])
263
+ existing["source"] = f"{existing['source']},{item['source']}"
264
+ else:
265
+ candidates[item["url"]] = item
266
+
267
+ ordered = sorted(candidates.values(), key=lambda item: item["score"], reverse=True)
268
+ return {"candidates": ordered[:max_terms_pages]}
269
+
270
+ def _extract_candidate_links(self, page_url: str, origin: str) -> List[Dict[str, Any]]:
271
+ links: List[Dict[str, Any]] = []
272
+ response = self._safe_get(page_url, timeout=10)
273
+ if not response or "html" not in response.headers.get("Content-Type", "").lower():
274
+ return links
275
+
276
+ soup = BeautifulSoup(response.text[:300000], "html.parser")
277
+ for anchor in soup.find_all("a", href=True):
278
+ href = anchor.get("href", "").strip()
279
+ text = " ".join(anchor.stripped_strings).strip().lower()
280
+ if not href:
281
+ continue
282
+
283
+ absolute = urljoin(page_url, href)
284
+ parsed = urlparse(absolute)
285
+ if not parsed.scheme.startswith("http"):
286
+ continue
287
+ if f"{parsed.scheme}://{parsed.netloc}" != origin:
288
+ continue
289
+
290
+ score = self._score_policy_link(absolute.lower(), text)
291
+ if score <= 0:
292
+ continue
293
+
294
+ links.append(
295
+ {
296
+ "url": absolute,
297
+ "score": score,
298
+ "source": "link_discovery",
299
+ "label": text or parsed.path,
300
+ }
301
+ )
302
+
303
+ return links
304
+
305
+ def _score_policy_link(self, href: str, text: str) -> int:
306
+ combined = f"{href} {text}".lower()
307
+ score = 0
308
+ for keyword, weight in self.POLICY_KEYWORDS.items():
309
+ if keyword in combined:
310
+ score += weight
311
+ return score
312
+
313
+ def _evaluate_policy_pages(
314
+ self,
315
+ normalized: Dict[str, Any],
316
+ policy_candidates: Dict[str, List[Dict[str, Any]]],
317
+ max_terms_pages: int,
318
+ ) -> Dict[str, Any]:
319
+ pages_evaluated = []
320
+ clause_hits = []
321
+
322
+ for candidate in policy_candidates.get("candidates", [])[:max_terms_pages]:
323
+ url = candidate["url"]
324
+ response = self._safe_get(url, timeout=12)
325
+ if not response:
326
+ continue
327
+
328
+ content_type = response.headers.get("Content-Type", "").lower()
329
+ if "html" not in content_type:
330
+ pages_evaluated.append(
331
+ {
332
+ "url": url,
333
+ "status": "skipped",
334
+ "reason": f"Unsupported content type: {content_type or 'unknown'}",
335
+ }
336
+ )
337
+ continue
338
+
339
+ extracted_sections = self._extract_policy_sections(response.text)
340
+ page_hits = self._score_policy_sections(
341
+ normalized["action_type"], extracted_sections, url
342
+ )
343
+ clause_hits.extend(page_hits)
344
+ pages_evaluated.append(
345
+ {
346
+ "url": url,
347
+ "status": "parsed",
348
+ "candidate_score": candidate["score"],
349
+ "matched_clauses": len(page_hits),
350
+ }
351
+ )
352
+
353
+ clause_hits.sort(key=lambda item: abs(item["score_delta"]), reverse=True)
354
+ aggregate_score = sum(item["score_delta"] for item in clause_hits)
355
+ if not pages_evaluated:
356
+ status = "insufficient_evidence"
357
+ summary = "No candidate Terms or policy pages could be parsed."
358
+ elif any(item["classification"] == "hard_block" for item in clause_hits):
359
+ status = "blocked"
360
+ summary = "Discovered policy text contains an explicit restriction on the requested automated behavior."
361
+ elif aggregate_score <= -20:
362
+ status = "caution"
363
+ summary = "Discovered policy text suggests restrictions or conditions on the requested automated behavior."
364
+ elif aggregate_score > 0:
365
+ status = "allowed"
366
+ summary = "Discovered policy text includes language that appears permissive for the requested behavior."
367
+ else:
368
+ status = "insufficient_evidence"
369
+ summary = "Policy pages were found, but none produced strong action-specific evidence."
370
+
371
+ return {
372
+ "status": status,
373
+ "summary": summary,
374
+ "pages_evaluated": pages_evaluated,
375
+ "matched_clauses": clause_hits[:10],
376
+ "aggregate_score": aggregate_score,
377
+ }
378
+
379
+ def _extract_policy_sections(self, html: str) -> List[Dict[str, str]]:
380
+ soup = BeautifulSoup(html[:400000], "html.parser")
381
+ for tag in soup(["script", "style", "noscript", "svg"]):
382
+ tag.decompose()
383
+
384
+ body = soup.body or soup
385
+ sections: List[Dict[str, str]] = []
386
+ current_heading = "General"
387
+
388
+ for element in body.find_all(["h1", "h2", "h3", "p", "li"]):
389
+ text = " ".join(element.stripped_strings)
390
+ text = re.sub(r"\s+", " ", text).strip()
391
+ if not text or len(text) < 20:
392
+ continue
393
+
394
+ if element.name in {"h1", "h2", "h3"}:
395
+ current_heading = text[:160]
396
+ continue
397
+
398
+ sections.append({"heading": current_heading, "text": text[:1200]})
399
+
400
+ return sections[:200]
401
+
402
+ def _score_policy_sections(
403
+ self, action_type: str, sections: List[Dict[str, str]], page_url: str
404
+ ) -> List[Dict[str, Any]]:
405
+ hits = []
406
+ action_relevance_patterns = self.ACTION_PATTERNS.get(action_type, [])
407
+ for section in sections:
408
+ text_lower = section["text"].lower()
409
+ heading_lower = section["heading"].lower()
410
+ combined = f"{heading_lower} {text_lower}"
411
+
412
+ if action_relevance_patterns and not any(
413
+ re.search(pattern, combined) for pattern in action_relevance_patterns
414
+ ):
415
+ generic_automation = re.search(
416
+ r"automated|bot|crawl|scrap|harvest|api|programmatic", combined
417
+ )
418
+ if not generic_automation:
419
+ continue
420
+
421
+ for clause in self.CLAUSE_PATTERNS:
422
+ for pattern in clause["patterns"]:
423
+ if re.search(pattern, combined):
424
+ hits.append(
425
+ {
426
+ "url": page_url,
427
+ "heading": section["heading"],
428
+ "snippet": section["text"],
429
+ "classification": clause["label"],
430
+ "severity": clause["severity"],
431
+ "score_delta": clause["weight"],
432
+ }
433
+ )
434
+ break
435
+
436
+ return hits
437
+
438
+ def _should_use_llm(
439
+ self,
440
+ normalized: Dict[str, Any],
441
+ robots_assessment: Dict[str, Any],
442
+ tos_assessment: Dict[str, Any],
443
+ ) -> bool:
444
+ if not normalized.get("use_llm_evaluator"):
445
+ return False
446
+ if tos_assessment["status"] in {"blocked", "allowed"}:
447
+ return False
448
+ if robots_assessment.get("can_fetch") is False:
449
+ return False
450
+ return bool(tos_assessment.get("matched_clauses") or tos_assessment["status"] == "caution")
451
+
452
+ def _run_llm_evaluator(
453
+ self, normalized: Dict[str, Any], tos_assessment: Dict[str, Any]
454
+ ) -> Dict[str, Any]:
455
+ provider = normalized["llm_provider"].lower()
456
+ if provider != "gemini":
457
+ return {
458
+ "status": "skipped",
459
+ "reason": f"Unsupported llm_provider '{normalized['llm_provider']}'.",
460
+ }
461
+ if genai is None:
462
+ return {"status": "skipped", "reason": "google-generativeai is not installed."}
463
+
464
+ api_key = os.environ.get("GOOGLE_API_KEY")
465
+ if not api_key:
466
+ return {"status": "skipped", "reason": "GOOGLE_API_KEY is not configured."}
467
+
468
+ genai.configure(api_key=api_key)
469
+ prompt = {
470
+ "target_url": normalized["target_url"],
471
+ "intended_action": normalized["intended_action"],
472
+ "action_type": normalized["action_type"],
473
+ "matched_clauses": tos_assessment.get("matched_clauses", [])[:6],
474
+ "task": (
475
+ "Classify whether these policy snippets forbid, allow, or condition "
476
+ "the requested action. Return strict JSON with keys: "
477
+ "verdict, confidence_score, rationale."
478
+ ),
479
+ }
480
+
481
+ try:
482
+ model = genai.GenerativeModel(normalized["llm_model"])
483
+ response = model.generate_content(
484
+ json.dumps(prompt, ensure_ascii=True),
485
+ generation_config=genai.GenerationConfig(
486
+ response_mime_type="application/json", temperature=0.0
487
+ ),
488
+ )
489
+ parsed = json.loads(response.text)
490
+ return {
491
+ "status": "used",
492
+ "provider": provider,
493
+ "model": normalized["llm_model"],
494
+ "verdict": parsed.get("verdict", "CAUTION"),
495
+ "confidence_score": float(parsed.get("confidence_score", 0.5)),
496
+ "rationale": parsed.get("rationale", "No rationale returned."),
497
+ }
498
+ except Exception as exc:
499
+ return {"status": "error", "reason": f"LLM evaluator failed: {str(exc)}"}
500
+
501
+ def _build_final_result(
502
+ self,
503
+ normalized: Dict[str, Any],
504
+ robots_assessment: Dict[str, Any],
505
+ tos_assessment: Dict[str, Any],
506
+ llm_assessment: Optional[Dict[str, Any]],
507
+ policy_candidates: Dict[str, List[Dict[str, Any]]],
508
+ ) -> Dict[str, Any]:
509
+ verdict = "INSUFFICIENT_EVIDENCE"
510
+ confidence_score = 0.35
511
+ reason = "Insufficient policy evidence to safely approve the requested action."
512
+ recommended_next_step = "Review the discovered policy pages manually before proceeding."
513
+
514
+ if robots_assessment.get("can_fetch") is False:
515
+ verdict = "UNSAFE"
516
+ confidence_score = 0.98
517
+ reason = robots_assessment["reason"]
518
+ recommended_next_step = (
519
+ "Do not automate access to this path unless you have explicit permission."
520
+ )
521
+ elif tos_assessment["status"] == "blocked":
522
+ verdict = "UNSAFE"
523
+ confidence_score = 0.9
524
+ reason = tos_assessment["summary"]
525
+ recommended_next_step = "Avoid the requested action or obtain explicit written permission."
526
+ elif tos_assessment["status"] == "caution":
527
+ verdict = "CAUTION"
528
+ confidence_score = 0.65
529
+ reason = tos_assessment["summary"]
530
+ recommended_next_step = "Prefer an official API or documented integration path if one exists."
531
+ elif tos_assessment["status"] == "allowed" and robots_assessment.get("can_fetch") is not False:
532
+ verdict = "SAFE"
533
+ confidence_score = 0.72
534
+ reason = tos_assessment["summary"]
535
+ recommended_next_step = "Proceed conservatively and continue honoring crawl delays and rate limits."
536
+
537
+ if llm_assessment and llm_assessment.get("status") == "used":
538
+ verdict = llm_assessment.get("verdict", verdict)
539
+ confidence_score = max(
540
+ confidence_score,
541
+ llm_assessment.get("confidence_score", confidence_score),
542
+ )
543
+ reason = llm_assessment.get("rationale", reason)
544
+
545
+ evidence = []
546
+ if robots_assessment.get("reason"):
547
+ evidence.append(
548
+ {
549
+ "source": robots_assessment.get("robots_url"),
550
+ "type": "robots",
551
+ "snippet": robots_assessment["reason"],
552
+ }
553
+ )
554
+ for clause in tos_assessment.get("matched_clauses", [])[:5]:
555
+ evidence.append(
556
+ {
557
+ "source": clause["url"],
558
+ "type": clause["classification"],
559
+ "heading": clause["heading"],
560
+ "snippet": clause["snippet"],
561
+ }
562
+ )
563
+
564
+ return {
565
+ "is_safe_to_proceed": verdict == "SAFE",
566
+ "confidence_score": round(float(confidence_score), 2),
567
+ "verdict": verdict,
568
+ "reason": reason,
569
+ "recommended_next_step": recommended_next_step,
570
+ "action_type": normalized["action_type"],
571
+ "robots_assessment": robots_assessment,
572
+ "tos_assessment": tos_assessment,
573
+ "llm_assessment": llm_assessment or {"status": "not_used"},
574
+ "discovered_policy_urls": {
575
+ "candidates": [item["url"] for item in policy_candidates.get("candidates", [])]
576
+ },
577
+ "evidence": evidence,
578
+ }
579
+
580
+ def _safe_get(self, url: str, timeout: int = 10) -> Optional[requests.Response]:
581
+ try:
582
+ response = self.session.get(url, timeout=timeout, allow_redirects=True)
583
+ if response.status_code >= 400:
584
+ return None
585
+ return response
586
+ except requests.RequestException:
587
+ return None
@@ -0,0 +1,30 @@
1
+ import os
2
+
3
+ import pytest
4
+ import yaml
5
+
6
+ from .skill import TOSEvaluatorSkill
7
+
8
+
9
+ @pytest.fixture
10
+ def skill():
11
+ return TOSEvaluatorSkill()
12
+
13
+
14
+ @pytest.fixture
15
+ def manifest():
16
+ manifest_path = os.path.join(os.path.dirname(__file__), "manifest.yaml")
17
+ with open(manifest_path, "r", encoding="utf-8") as f:
18
+ return yaml.safe_load(f)
19
+
20
+
21
+ def test_skill_manifest_consistency(skill, manifest):
22
+ skill_manifest = skill.manifest
23
+ assert skill_manifest["name"] == manifest["name"]
24
+ assert skill_manifest["version"] == manifest["version"]
25
+
26
+
27
+ def test_skill_execution_requires_inputs(skill):
28
+ result = skill.execute({})
29
+ assert "error" in result
30
+ assert "target_url" in result["error"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillware
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: A framework for modular, self-contained AI skills.
5
5
  Author-email: ARPA Hellenic Logic Systems <skillware-os@arpacorp.net>
6
6
  License: MIT License
@@ -38,6 +38,7 @@ Requires-Dist: pyyaml
38
38
  Requires-Dist: anthropic
39
39
  Requires-Dist: google-generativeai
40
40
  Requires-Dist: pymupdf
41
+ Requires-Dist: beautifulsoup4
41
42
  Provides-Extra: dev
42
43
  Requires-Dist: pytest; extra == "dev"
43
44
  Requires-Dist: pytest-mock; extra == "dev"
@@ -102,7 +103,8 @@ Skillware/
102
103
  │ └── skill_name/ # The Skill bundle
103
104
  │ ├── manifest.yaml # Definition, schema, and constitution
104
105
  │ ├── skill.py # Executable Python logic
105
- └── instructions.md # Cognitive map for the LLM
106
+ ├── instructions.md # Cognitive map for the LLM
107
+ │ └── test_skill.py # Unit tests & schema validation
106
108
  ├── skillware/ # Core Framework Package
107
109
  │ └── core/
108
110
  │ ├── base_skill.py # Abstract Base Class for skills
@@ -172,6 +174,7 @@ print(response.text)
172
174
  ## Documentation
173
175
 
174
176
  * **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
177
+ * **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
175
178
  * **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
176
179
  * **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
177
180
  * **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
@@ -1,8 +1,13 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ skills/compliance/mica_module/__init__.py
5
+ skills/compliance/mica_module/skill.py
4
6
  skills/compliance/pii_masker/__init__.py
5
7
  skills/compliance/pii_masker/skill.py
8
+ skills/compliance/tos_evaluator/__init__.py
9
+ skills/compliance/tos_evaluator/skill.py
10
+ skills/compliance/tos_evaluator/test_skill.py
6
11
  skills/data_engineering/synthetic_generator/__init__.py
7
12
  skills/data_engineering/synthetic_generator/skill.py
8
13
  skills/finance/wallet_screening/__init__.py
@@ -23,4 +28,5 @@ skillware/core/__init__.py
23
28
  skillware/core/base_skill.py
24
29
  skillware/core/env.py
25
30
  skillware/core/loader.py
26
- tests/test_loader.py
31
+ tests/test_loader.py
32
+ tests/test_mica_module.py
@@ -3,6 +3,7 @@ pyyaml
3
3
  anthropic
4
4
  google-generativeai
5
5
  pymupdf
6
+ beautifulsoup4
6
7
 
7
8
  [dev]
8
9
  pytest
@@ -0,0 +1,66 @@
1
+ import pytest
2
+ from skillware.core.loader import SkillLoader
3
+
4
+
5
+ # Fixture to load the skill module
6
+ @pytest.fixture
7
+ def mica_skill():
8
+ skill_bundle = SkillLoader.load_skill("compliance/mica_module")
9
+ MiCAModuleSkill = skill_bundle["module"].MiCAModuleSkill
10
+ return MiCAModuleSkill()
11
+
12
+
13
+ def test_mica_module_manifest(mica_skill):
14
+ manifest = mica_skill.manifest
15
+ assert manifest["name"] == "compliance/mica_module"
16
+ assert manifest["version"] == "0.1.0"
17
+
18
+
19
+ def test_mica_module_stateless_rag_execution(mica_skill):
20
+ # Test that the module correctly pulls information without running the evaluator
21
+ params = {
22
+ "user_prompt": "I want to issue an asset-referenced token. What are the authorization rules?",
23
+ "run_evaluator": False,
24
+ }
25
+
26
+ result = mica_skill.execute(params)
27
+
28
+ # Since run_evaluator is False, policy_status should default to CAUTION
29
+ assert result["policy_status"] == "CAUTION"
30
+
31
+ # It should have either found some chunks or correctly reported no matches
32
+ assert "retrieved_sections" in result
33
+ assert "final_context_for_agent" in result
34
+
35
+ feedback = result["gemini_evaluator_feedback"]
36
+ assert "Evaluator disabled" in feedback["holes_found"]
37
+
38
+
39
+ def test_mica_module_router_normalization(mica_skill):
40
+ # Verify that 'authorization' (US) matches 'authorisation' (UK)
41
+ mock_corpus = [
42
+ {
43
+ "title_num": "Title V",
44
+ "article_num": "Article 59",
45
+ "keywords": ["authorisation", "casp"],
46
+ "content": "CASP Authorization rules...",
47
+ }
48
+ ]
49
+ matched = mica_skill._route_and_fetch("Authorization of a CASP", mock_corpus)
50
+ assert len(matched) > 0
51
+ assert matched[0]["article_num"] == "Article 59"
52
+
53
+
54
+ def test_mica_module_router_deduplication(mica_skill):
55
+ # Verify that multiple keyword matches dont duplicate the same article
56
+ mock_corpus = [
57
+ {
58
+ "title_num": "Title V",
59
+ "article_num": "Article 59",
60
+ "keywords": ["authorisation", "casp"],
61
+ "content": "CASP Authorization rules...",
62
+ }
63
+ ]
64
+ # 'authorisation' and 'casp' both match
65
+ matched = mica_skill._route_and_fetch("authorisation casp", mock_corpus)
66
+ assert len(matched) == 1
File without changes
File without changes