budihoo commited on
Commit
c0c1e6b
·
verified ·
1 Parent(s): ba35ba9

Initial Commit

Browse files

## What it does
- Reads Instagram Story images from uploaded files
- Converts images into structured semantic descriptions using OpenAI
- Caches outputs to avoid repeated API calls
- Runs per-day audits (default: today, overridable via config)

Files changed (45) hide show
  1. README.md +24 -13
  2. app.py +66 -0
  3. config/modes.ini +30 -0
  4. config/settings.ini +25 -0
  5. requirements.txt +4 -0
  6. src/ig_story_audit.egg-info/PKG-INFO +3 -0
  7. src/ig_story_audit.egg-info/SOURCES.txt +20 -0
  8. src/ig_story_audit.egg-info/dependency_links.txt +1 -0
  9. src/ig_story_audit.egg-info/top_level.txt +1 -0
  10. src/ig_story_audit/__init__.py +0 -0
  11. src/ig_story_audit/__pycache__/__init__.cpython-312.pyc +0 -0
  12. src/ig_story_audit/__pycache__/main.cpython-312.pyc +0 -0
  13. src/ig_story_audit/io/__init__.py +0 -0
  14. src/ig_story_audit/io/__pycache__/__init__.cpython-312.pyc +0 -0
  15. src/ig_story_audit/io/__pycache__/instagram_story_fetcher.cpython-312.pyc +0 -0
  16. src/ig_story_audit/io/__pycache__/vision_cache.cpython-312.pyc +0 -0
  17. src/ig_story_audit/io/instagram_story_fetcher.py +107 -0
  18. src/ig_story_audit/io/vision_cache.py +16 -0
  19. src/ig_story_audit/judgement/__init__.py +0 -0
  20. src/ig_story_audit/judgement/__pycache__/__init__.cpython-312.pyc +0 -0
  21. src/ig_story_audit/judgement/__pycache__/goal_gap_analyser.cpython-312.pyc +0 -0
  22. src/ig_story_audit/judgement/__pycache__/narrative_ai_summary.cpython-312.pyc +0 -0
  23. src/ig_story_audit/judgement/__pycache__/narrative_evaluator.cpython-312.pyc +0 -0
  24. src/ig_story_audit/judgement/goal_gap_analyser.py +47 -0
  25. src/ig_story_audit/judgement/narrative_ai_summary.py +47 -0
  26. src/ig_story_audit/judgement/narrative_evaluator.py +79 -0
  27. src/ig_story_audit/judgement/qualitative.py +0 -0
  28. src/ig_story_audit/judgement/story_categoriser.py +34 -0
  29. src/ig_story_audit/main.py +161 -0
  30. src/ig_story_audit/utils/__init__.py +0 -0
  31. src/ig_story_audit/utils/__pycache__/__init__.cpython-312.pyc +0 -0
  32. src/ig_story_audit/utils/__pycache__/config.cpython-312.pyc +0 -0
  33. src/ig_story_audit/utils/__pycache__/date_utils.cpython-312.pyc +0 -0
  34. src/ig_story_audit/utils/__pycache__/goal_resolver.cpython-312.pyc +0 -0
  35. src/ig_story_audit/utils/__pycache__/modes_config.cpython-312.pyc +0 -0
  36. src/ig_story_audit/utils/config.py +12 -0
  37. src/ig_story_audit/utils/date_utils.py +15 -0
  38. src/ig_story_audit/utils/goal_resolver.py +31 -0
  39. src/ig_story_audit/utils/interactive_input.py +13 -0
  40. src/ig_story_audit/utils/logging.py +0 -0
  41. src/ig_story_audit/utils/modes_config.py +12 -0
  42. src/ig_story_audit/vision/__init__.py +0 -0
  43. src/ig_story_audit/vision/__pycache__/__init__.cpython-312.pyc +0 -0
  44. src/ig_story_audit/vision/__pycache__/openai_client.cpython-312.pyc +0 -0
  45. src/ig_story_audit/vision/openai_client.py +64 -0
README.md CHANGED
@@ -1,13 +1,24 @@
1
- ---
2
- title: IG Story Audit
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.1.0
8
- app_file: app.py
9
- pinned: false
10
- short_description: A lightweight audit system to analyse Instagram Stories usin
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Instagram Story Audit
2
+
3
+ A lightweight audit system to analyse Instagram Stories using OpenAI Vision,
4
+ store semantic interpretations, and evaluate compliance against configurable
5
+ policy rules.
6
+
7
+ ## What it does
8
+ - Reads Instagram Story images from disk
9
+ - Converts images into structured semantic descriptions using OpenAI
10
+ - Caches outputs to avoid repeated API calls
11
+ - Runs per-day audits (default: today, overridable via config)
12
+
13
+ ## Project structure
14
+ - `data/raw_stories/` — input Story images
15
+ - `data/vision_output/` — cached AI vision outputs (JSON)
16
+ - `config/settings.ini` — runtime configuration
17
+ - `config/modes.ini` — audit policy definitions
18
+
19
+ ## Running locally
20
+ ```bash
21
+ source venv/bin/activate
22
+ pip install -e .
23
+ export OPENAI_API_KEY=your_key_here
24
+ python -m ig_story_audit.main
app.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from pathlib import Path
3
+ import shutil
4
+
5
+ from ig_story_audit.main import main as run_audit
6
+
7
+
8
+ UPLOAD_DIR = Path("data/raw_stories")
9
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
10
+
11
+
12
+ def audit_stories(files, goal, language):
13
+ # Clear previous uploads
14
+ for f in UPLOAD_DIR.iterdir():
15
+ f.unlink()
16
+
17
+ # Save uploaded files
18
+ for file in files:
19
+ shutil.copy(file.name, UPLOAD_DIR / Path(file.name).name)
20
+
21
+ # Override config dynamically (simple approach)
22
+ from ig_story_audit.utils.config import load_config
23
+ config = load_config()
24
+ config["general"]["language"] = language
25
+
26
+ # Run audit
27
+ run_audit()
28
+
29
+ # Read latest audit output
30
+ audit_dir = Path("data/audit_logs")
31
+ latest = max(audit_dir.glob("*_narrative_audit.txt"), key=lambda p: p.stat().st_mtime)
32
+
33
+ return latest.read_text(encoding="utf-8")
34
+
35
+
36
+ with gr.Blocks() as demo:
37
+ gr.Markdown("## Instagram Story Narrative Audit")
38
+
39
+ files = gr.File(
40
+ file_types=["image"],
41
+ file_count="multiple",
42
+ label="Upload Instagram Story images (JPG/PNG)",
43
+ )
44
+
45
+ goal = gr.Textbox(
46
+ label="Audit goal",
47
+ placeholder="e.g. Reinforce the feeling of tiredness through repetition",
48
+ )
49
+
50
+ language = gr.Dropdown(
51
+ choices=["id", "en"],
52
+ value="id",
53
+ label="Output language",
54
+ )
55
+
56
+ run_btn = gr.Button("Run Audit")
57
+
58
+ output = gr.Textbox(label="Audit Result", lines=20)
59
+
60
+ run_btn.click(
61
+ audit_stories,
62
+ inputs=[files, goal, language],
63
+ outputs=output,
64
+ )
65
+
66
+ demo.launch()
config/modes.ini ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [modes]
2
+ active = narrative.universal_massage_awareness
3
+
4
+ ; ==================================================
5
+ ; NARRATIVE GOALS
6
+ ; Evaluated at STORY-SERIES level (per day)
7
+ ; ==================================================
8
+
9
+ [narrative.universal_massage_awareness]
10
+ description = Massage is relevant and needed by people from all walks of life
11
+
12
+ ; Minimum number of distinct subject groups represented across the series
13
+ min_subject_diversity = 2
14
+
15
+ ; Allowed subject groups inferred from stories
16
+ # allowed_subject_groups = individual, family, elderly, worker, general_public
17
+
18
+ ; At least one story must explicitly or implicitly generalise the need
19
+ require_universal_framing = true
20
+
21
+ ; Disallowed narrowing frames
22
+ forbidden_narratives = luxury_only, elite_only, athlete_only
23
+
24
+ ; Aggregate confidence threshold (mean of contributing stories)
25
+ min_aggregate_confidence = 0.65
26
+
27
+ ; Weighting (used for qualitative scoring, not pass/fail)
28
+ weights.subject_diversity = 0.4
29
+ weights.universal_framing = 0.4
30
+ weights.coherence = 0.2
config/settings.ini ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [general]
2
+ timezone = Asia/Jakarta
3
+ environment = development
4
+ language = id
5
+
6
+ [instagram]
7
+ handle = jiva.familymassage
8
+
9
+ [paths]
10
+ raw_stories_dir = data/raw_stories
11
+ vision_output_dir = data/vision_output
12
+ audit_logs_dir = data/audit_logs
13
+
14
+ [openai]
15
+ vision_model = gpt-5-nano
16
+ analysis_model = gpt-5-nano
17
+ timeout_seconds = 60
18
+
19
+ [vision]
20
+ min_confidence = 0.6
21
+ max_images_per_run = 20
22
+
23
+ [run]
24
+ date = 2025-12-14
25
+ # date = today
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ openai
2
+ python-dotenv
3
+ gradio
4
+ requests
src/ig_story_audit.egg-info/PKG-INFO ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: ig_story_audit
3
+ Version: 0.0.0
src/ig_story_audit.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ src/ig_story_audit/__init__.py
4
+ src/ig_story_audit/main.py
5
+ src/ig_story_audit.egg-info/PKG-INFO
6
+ src/ig_story_audit.egg-info/SOURCES.txt
7
+ src/ig_story_audit.egg-info/dependency_links.txt
8
+ src/ig_story_audit.egg-info/top_level.txt
9
+ src/ig_story_audit/io/__init__.py
10
+ src/ig_story_audit/io/loader.py
11
+ src/ig_story_audit/io/writer.py
12
+ src/ig_story_audit/judgement/__init__.py
13
+ src/ig_story_audit/judgement/qualitative.py
14
+ src/ig_story_audit/rules/__init__.py
15
+ src/ig_story_audit/rules/evaluator.py
16
+ src/ig_story_audit/utils/__init__.py
17
+ src/ig_story_audit/utils/logging.py
18
+ src/ig_story_audit/vision/__init__.py
19
+ src/ig_story_audit/vision/describe_image.py
20
+ src/ig_story_audit/vision/openai_client.py
src/ig_story_audit.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/ig_story_audit.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ig_story_audit
src/ig_story_audit/__init__.py ADDED
File without changes
src/ig_story_audit/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (179 Bytes). View file
 
src/ig_story_audit/__pycache__/main.cpython-312.pyc ADDED
Binary file (5.81 kB). View file
 
src/ig_story_audit/io/__init__.py ADDED
File without changes
src/ig_story_audit/io/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (182 Bytes). View file
 
src/ig_story_audit/io/__pycache__/instagram_story_fetcher.cpython-312.pyc ADDED
Binary file (4.46 kB). View file
 
src/ig_story_audit/io/__pycache__/vision_cache.cpython-312.pyc ADDED
Binary file (1.42 kB). View file
 
src/ig_story_audit/io/instagram_story_fetcher.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from pathlib import Path
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from ig_story_audit.utils.config import load_config
7
+
8
+
9
+ HEADERS = {
10
+ "User-Agent": (
11
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
12
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
13
+ "Chrome/120.0.0.0 Safari/537.36"
14
+ ),
15
+ "Accept": "*/*",
16
+ }
17
+
18
+
19
+ def stories_already_fetched(run_date: str, output_dir: Path) -> bool:
20
+ if not output_dir.exists():
21
+ return False
22
+ return any(p.name.startswith(run_date) for p in output_dir.iterdir())
23
+
24
+
25
+ def resolve_user_id(username: str) -> Optional[str]:
26
+ """
27
+ Resolve Instagram numeric user ID from username
28
+ using a public, anonymous endpoint.
29
+ """
30
+ url = f"https://www.instagram.com/{username}/?__a=1&__d=dis"
31
+
32
+ resp = requests.get(url, headers=HEADERS, timeout=10)
33
+ if resp.status_code != 200:
34
+ return None
35
+
36
+ try:
37
+ data = resp.json()
38
+ return data["graphql"]["user"]["id"]
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def fetch_today_stories() -> None:
44
+ """
45
+ Fetch Instagram Stories for a public account using
46
+ anonymous, read-only access.
47
+ """
48
+ config = load_config()
49
+ username = config["instagram"]["handle"]
50
+ output_dir = Path(config["paths"]["raw_stories_dir"])
51
+ output_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ run_date = datetime.now().strftime("%Y-%m-%d")
54
+
55
+ # Enforce: fetch only once per day
56
+ if stories_already_fetched(run_date, output_dir):
57
+ print("Today's Instagram Stories already fetched. Skipping.")
58
+ return
59
+
60
+ user_id = resolve_user_id(username)
61
+ if not user_id:
62
+ raise RuntimeError("Unable to resolve Instagram user ID (profile may be private).")
63
+
64
+ reels_url = (
65
+ "https://www.instagram.com/api/v1/feed/reels_media/"
66
+ f"?reel_ids={user_id}"
67
+ )
68
+
69
+ resp = requests.get(reels_url, headers=HEADERS, timeout=10)
70
+ if resp.status_code != 200:
71
+ raise RuntimeError("Failed to fetch stories (anonymous access blocked).")
72
+
73
+ data = resp.json()
74
+ reels = data.get("reels", {})
75
+ user_reel = reels.get(user_id)
76
+
77
+ if not user_reel or "items" not in user_reel:
78
+ print("No active stories found.")
79
+ return
80
+
81
+ index = 1
82
+ for item in user_reel["items"]:
83
+ media_url = None
84
+ ext = "jpg"
85
+
86
+ if item.get("video_versions"):
87
+ media_url = item["video_versions"][0]["url"]
88
+ ext = "mp4"
89
+ elif item.get("image_versions2"):
90
+ media_url = item["image_versions2"]["candidates"][0]["url"]
91
+ ext = "jpg"
92
+
93
+ if not media_url:
94
+ continue
95
+
96
+ filename = output_dir / f"{run_date}_story_{index}.{ext}"
97
+
98
+ print(f"Downloading story {index}...")
99
+ media_resp = requests.get(media_url, headers=HEADERS, timeout=15)
100
+ media_resp.raise_for_status()
101
+
102
+ with open(filename, "wb") as f:
103
+ f.write(media_resp.content)
104
+
105
+ index += 1
106
+
107
+ print("Instagram Story fetch completed (anonymous).")
src/ig_story_audit/io/vision_cache.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ def get_output_path(image_path: Path, output_dir: Path) -> Path:
5
+ return output_dir / f"{image_path.stem}.json"
6
+
7
+ def load_cached_output(output_path: Path) -> dict | None:
8
+ if output_path.exists():
9
+ with open(output_path, "r", encoding="utf-8") as f:
10
+ return json.load(f)
11
+ return None
12
+
13
+ def save_output(output_path: Path, data: dict):
14
+ output_path.parent.mkdir(parents=True, exist_ok=True)
15
+ with open(output_path, "w", encoding="utf-8") as f:
16
+ json.dump(data, f, ensure_ascii=False, indent=2)
src/ig_story_audit/judgement/__init__.py ADDED
File without changes
src/ig_story_audit/judgement/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (189 Bytes). View file
 
src/ig_story_audit/judgement/__pycache__/goal_gap_analyser.cpython-312.pyc ADDED
Binary file (1.58 kB). View file
 
src/ig_story_audit/judgement/__pycache__/narrative_ai_summary.cpython-312.pyc ADDED
Binary file (1.71 kB). View file
 
src/ig_story_audit/judgement/__pycache__/narrative_evaluator.cpython-312.pyc ADDED
Binary file (3.89 kB). View file
 
src/ig_story_audit/judgement/goal_gap_analyser.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ig_story_audit.vision.openai_client import OpenAIClient
2
+ from ig_story_audit.utils.config import load_config
3
+
4
+
5
+ GOAL_EVALUATION_PROMPT = """
6
+ You are an internal communication auditor.
7
+
8
+ Language requirement:
9
+ Write your response in the following language: {language}
10
+
11
+ Context:
12
+ The following is a narrative summary of Instagram Stories posted on a single day.
13
+
14
+ Narrative summary:
15
+ {summary}
16
+
17
+ Audit goal:
18
+ {goal}
19
+
20
+ Your tasks:
21
+ 1. Clearly state whether the goal is achieved or not.
22
+ 2. Explain your reasoning in a calm, factual, human-readable way.
23
+ 3. If the goal is NOT achieved, explain what is missing or weak.
24
+ 4. Do NOT use bullet points unless necessary.
25
+ 5. Do NOT return JSON.
26
+ 6. Do NOT give marketing advice.
27
+
28
+ Write 1–3 short paragraphs.
29
+ """
30
+
31
+
32
+ def evaluate_goal_human_readable(
33
+ goal: str,
34
+ summary: str,
35
+ ) -> str:
36
+ config = load_config()
37
+ language = config["general"].get("language", "en")
38
+
39
+ client = OpenAIClient()
40
+
41
+ prompt = GOAL_EVALUATION_PROMPT.format(
42
+ goal=goal,
43
+ summary=summary,
44
+ language=language,
45
+ )
46
+
47
+ return client.describe_text(prompt).strip()
src/ig_story_audit/judgement/narrative_ai_summary.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from ig_story_audit.vision.openai_client import OpenAIClient
3
+ from ig_story_audit.utils.config import load_config
4
+
5
+
6
+ SUMMARY_PROMPT = """
7
+ You are an internal audit assistant.
8
+
9
+ Language requirement:
10
+ Write the summary in the following language: {language}
11
+
12
+ Narrative goal:
13
+ {goal}
14
+
15
+ Below is a list of story analysis results (JSON), in chronological order.
16
+ Each item represents one Instagram Story frame.
17
+
18
+ Your task:
19
+ - Write a concise, human-readable summary (3–5 sentences)
20
+ - Describe what the series of stories communicates as a whole
21
+ - Base your summary ONLY on the provided data
22
+ - Do NOT invent details
23
+ - Do NOT give advice or marketing suggestions
24
+
25
+ Story data:
26
+ {stories_json}
27
+
28
+ Return plain text only.
29
+ """
30
+
31
+
32
+ def generate_ai_narrative_summary(
33
+ stories: list[dict],
34
+ narrative_description: str,
35
+ ) -> str:
36
+ config = load_config()
37
+ language = config["general"].get("language", "en")
38
+
39
+ client = OpenAIClient()
40
+
41
+ prompt = SUMMARY_PROMPT.format(
42
+ goal=narrative_description,
43
+ stories_json=json.dumps(stories, ensure_ascii=False, indent=2),
44
+ language=language,
45
+ )
46
+
47
+ return client.describe_text(prompt).strip()
src/ig_story_audit/judgement/narrative_evaluator.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from statistics import mean
2
+
3
+ UNIVERSAL_KEYWORDS = {
4
+ "semua", "banyak", "keluarga", "orang tua", "ayah", "ibu", "anak"
5
+ }
6
+
7
+ SUBJECT_KEYWORDS = {
8
+ "family": {"keluarga", "ayah", "ibu", "anak"},
9
+ "elderly": {"orang tua", "lansia"},
10
+ "worker": {"kerja", "pegal"},
11
+ }
12
+
13
+ FORBIDDEN_NARRATIVES = {
14
+ "elite_only": {"mewah", "eksklusif"},
15
+ "athlete_only": {"atlet", "sport"}
16
+ }
17
+
18
+
19
+ def infer_subject_group(story: dict) -> str:
20
+ text = " ".join(story.get("text_present", [])).lower()
21
+
22
+ for group, keywords in SUBJECT_KEYWORDS.items():
23
+ if any(k in text for k in keywords):
24
+ return group
25
+
26
+ return "individual"
27
+
28
+
29
+ def has_universal_framing(story: dict) -> bool:
30
+ text = " ".join(story.get("text_present", [])).lower()
31
+ return any(k in text for k in UNIVERSAL_KEYWORDS)
32
+
33
+
34
+ def violates_forbidden_narrative(story: dict) -> bool:
35
+ text = " ".join(story.get("text_present", [])).lower()
36
+ for keywords in FORBIDDEN_NARRATIVES.values():
37
+ if any(k in text for k in keywords):
38
+ return True
39
+ return False
40
+
41
+
42
+ def evaluate_narrative(stories: list[dict], narrative_cfg: dict) -> dict:
43
+ subject_groups = set()
44
+ universal_hits = 0
45
+ confidences = []
46
+
47
+ for story in stories:
48
+ subject_groups.add(infer_subject_group(story))
49
+ if has_universal_framing(story):
50
+ universal_hits += 1
51
+ if violates_forbidden_narrative(story):
52
+ return {
53
+ "fulfilled": False,
54
+ "reason": "Forbidden narrative detected"
55
+ }
56
+ confidences.append(story.get("confidence", 0))
57
+
58
+ diversity_ok = len(subject_groups) >= int(
59
+ narrative_cfg["min_subject_diversity"]
60
+ )
61
+
62
+ universal_ok = (
63
+ not narrative_cfg.get("require_universal_framing") or universal_hits > 0
64
+ )
65
+
66
+ confidence_ok = mean(confidences) >= float(
67
+ narrative_cfg["min_aggregate_confidence"]
68
+ )
69
+
70
+ fulfilled = diversity_ok and universal_ok and confidence_ok
71
+
72
+ return {
73
+ "fulfilled": fulfilled,
74
+ "subject_groups_detected": list(subject_groups),
75
+ "avg_confidence": round(mean(confidences), 2),
76
+ "universal_framing_present": universal_hits > 0,
77
+ "diversity_ok": diversity_ok,
78
+ "confidence_ok": confidence_ok
79
+ }
src/ig_story_audit/judgement/qualitative.py ADDED
File without changes
src/ig_story_audit/judgement/story_categoriser.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from ig_story_audit.vision.openai_client import OpenAIClient
3
+
4
+ CATEGORISATION_PROMPT = """
5
+ You are analysing a single Instagram Story.
6
+
7
+ User-defined goal:
8
+ {goal}
9
+
10
+ Story analysis data (JSON):
11
+ {story_json}
12
+
13
+ Your tasks:
14
+ 1. Assign a short message category (e.g. education, promo, testimonial, filler, repost, brand)
15
+ 2. Decide how relevant this story is to the user-defined goal (0.0–1.0)
16
+ 3. Briefly explain why
17
+
18
+ Return JSON only with keys:
19
+ - category
20
+ - relevance_score
21
+ - rationale
22
+ """
23
+
24
+
25
+ def categorise_story(story: dict, goal: str) -> dict:
26
+ client = OpenAIClient()
27
+
28
+ prompt = CATEGORISATION_PROMPT.format(
29
+ goal=goal,
30
+ story_json=json.dumps(story, ensure_ascii=False, indent=2),
31
+ )
32
+
33
+ response = client.describe_text(prompt)
34
+ return json.loads(response)
src/ig_story_audit/main.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ load_dotenv()
3
+
4
+ import json
5
+ import argparse
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+
9
+ from ig_story_audit.vision.openai_client import OpenAIClient
10
+ from ig_story_audit.utils.config import load_config
11
+ from ig_story_audit.utils.goal_resolver import resolve_goal_and_date
12
+ from ig_story_audit.judgement.narrative_ai_summary import (
13
+ generate_ai_narrative_summary,
14
+ )
15
+ from ig_story_audit.judgement.goal_gap_analyser import (
16
+ evaluate_goal_human_readable,
17
+ )
18
+ from ig_story_audit.io.vision_cache import (
19
+ get_output_path,
20
+ load_cached_output,
21
+ save_output,
22
+ )
23
+ from ig_story_audit.io.instagram_story_fetcher import fetch_today_stories
24
+
25
+ fetch_today_stories()
26
+
27
+ VISION_PROMPT = """
28
+ Describe the image factually and neutrally.
29
+ Do not evaluate quality or effectiveness.
30
+ Return JSON only with:
31
+ - visual_elements
32
+ - text_present
33
+ - implied_message
34
+ - dominant_intent
35
+ - tone
36
+ - confidence
37
+ """
38
+
39
+
40
+ def ensure_vision_outputs(run_date: str) -> list[dict]:
41
+ config = load_config()
42
+ raw_dir = Path(config["paths"]["raw_stories_dir"])
43
+ output_dir = Path(config["paths"]["vision_output_dir"])
44
+ output_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ image_files = sorted(
47
+ p for p in raw_dir.iterdir()
48
+ if p.is_file()
49
+ and p.suffix.lower() in {".jpg", ".jpeg", ".png"}
50
+ and p.name.startswith(run_date)
51
+ )
52
+
53
+ if not image_files:
54
+ print(f"No raw story images found for date {run_date}")
55
+ return []
56
+
57
+ client = OpenAIClient()
58
+ results: list[dict] = []
59
+
60
+ for image_path in image_files:
61
+ output_path = get_output_path(image_path, output_dir)
62
+
63
+ cached = load_cached_output(output_path)
64
+ if cached:
65
+ results.append(cached)
66
+ continue
67
+
68
+ print(f"Running vision model on {image_path.name}...")
69
+
70
+ raw_text = client.describe_image(
71
+ image_path=str(image_path),
72
+ prompt=VISION_PROMPT,
73
+ )
74
+
75
+ result = json.loads(raw_text)
76
+ save_output(output_path, result)
77
+ results.append(result)
78
+
79
+ return results
80
+
81
+
82
+ def persist_audit_result(
83
+ run_date: str,
84
+ goal: str,
85
+ summary: str,
86
+ evaluation_text: str,
87
+ ):
88
+ config = load_config()
89
+ audit_dir = Path(config["paths"]["audit_logs_dir"])
90
+ audit_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
93
+ path = audit_dir / f"{timestamp}_narrative_audit.txt"
94
+
95
+ with open(path, "w", encoding="utf-8") as f:
96
+ f.write(f"Date: {run_date}\n")
97
+ f.write(f"Goal: {goal}\n\n")
98
+
99
+ f.write("=== Narrative Summary ===\n")
100
+ f.write(summary.strip())
101
+ f.write("\n\n")
102
+
103
+ f.write("=== Goal Evaluation ===\n")
104
+ f.write(evaluation_text.strip())
105
+
106
+ print(f"\nAudit written to: {path}")
107
+
108
+
109
+ def main():
110
+ parser = argparse.ArgumentParser()
111
+ parser.add_argument(
112
+ "--interactive",
113
+ action="store_true",
114
+ help="Prompt for goal and date interactively",
115
+ )
116
+ args = parser.parse_args()
117
+
118
+ # 1. Resolve goal + date
119
+ goal, run_date, is_preset = resolve_goal_and_date(args.interactive)
120
+
121
+ print("\n=== Audit Configuration ===")
122
+ print(f"Goal: {goal}")
123
+ print(f"Date: {run_date}")
124
+ print(f"Mode: {'preset' if is_preset else 'interactive'}")
125
+
126
+ # 2. Ensure vision outputs
127
+ stories = ensure_vision_outputs(run_date)
128
+
129
+ if not stories:
130
+ print("No stories available for audit.")
131
+ return
132
+
133
+ # 3. Narrative AI summary
134
+ summary = generate_ai_narrative_summary(
135
+ stories=stories,
136
+ narrative_description=goal,
137
+ )
138
+
139
+ print("\n=== Narrative Summary ===")
140
+ print(summary)
141
+
142
+ # 4. Human-readable goal evaluation
143
+ evaluation_text = evaluate_goal_human_readable(
144
+ goal=goal,
145
+ summary=summary,
146
+ )
147
+
148
+ print("\n=== Goal Evaluation ===")
149
+ print(evaluation_text)
150
+
151
+ # 5. Persist audit
152
+ persist_audit_result(
153
+ run_date=run_date,
154
+ goal=goal,
155
+ summary=summary,
156
+ evaluation_text=evaluation_text,
157
+ )
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
src/ig_story_audit/utils/__init__.py ADDED
File without changes
src/ig_story_audit/utils/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (185 Bytes). View file
 
src/ig_story_audit/utils/__pycache__/config.cpython-312.pyc ADDED
Binary file (592 Bytes). View file
 
src/ig_story_audit/utils/__pycache__/date_utils.cpython-312.pyc ADDED
Binary file (711 Bytes). View file
 
src/ig_story_audit/utils/__pycache__/goal_resolver.cpython-312.pyc ADDED
Binary file (1.47 kB). View file
 
src/ig_story_audit/utils/__pycache__/modes_config.cpython-312.pyc ADDED
Binary file (593 Bytes). View file
 
src/ig_story_audit/utils/config.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import configparser
2
+ from pathlib import Path
3
+
4
+ _CONFIG = None
5
+
6
+ def load_config():
7
+ global _CONFIG
8
+ if _CONFIG is None:
9
+ parser = configparser.ConfigParser()
10
+ parser.read(Path("config/settings.ini"))
11
+ _CONFIG = parser
12
+ return _CONFIG
src/ig_story_audit/utils/date_utils.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from ig_story_audit.utils.config import load_config
3
+
4
+ def get_run_date() -> str:
5
+ config = load_config()
6
+
7
+ if "run" in config and "date" in config["run"]:
8
+ date_value = config["run"]["date"]
9
+ else:
10
+ date_value = "today"
11
+
12
+ if date_value == "today":
13
+ return datetime.now().strftime("%Y-%m-%d")
14
+
15
+ return date_value
src/ig_story_audit/utils/goal_resolver.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from ig_story_audit.utils.modes_config import load_modes
3
+
4
+
5
+ def resolve_goal_and_date(interactive: bool) -> tuple[str, str, bool]:
6
+ """
7
+ Returns:
8
+ - goal_description (str)
9
+ - run_date (YYYY-MM-DD)
10
+ - is_preset (bool)
11
+ """
12
+ if interactive:
13
+ goal = input("Enter the goal you want to audit:\n> ").strip()
14
+
15
+ today_choice = input("Is the date today? (Y/N): ").strip().lower()
16
+ if today_choice == "y":
17
+ run_date = datetime.now().strftime("%Y-%m-%d")
18
+ else:
19
+ run_date = input("Enter date (YYYY-MM-DD): ").strip()
20
+
21
+ return goal, run_date, False
22
+
23
+ # preset mode
24
+ modes = load_modes()
25
+ active_mode = modes["modes"]["active"]
26
+ cfg = modes[active_mode]
27
+
28
+ goal = cfg.get("description", active_mode)
29
+ run_date = datetime.now().strftime("%Y-%m-%d")
30
+
31
+ return goal, run_date, True
src/ig_story_audit/utils/interactive_input.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ def prompt_for_goal_and_date() -> tuple[str, str]:
4
+ goal = input("Enter the goal you want to audit for this day:\n> ").strip()
5
+
6
+ today_choice = input("Is the date today? (Y/N): ").strip().lower()
7
+
8
+ if today_choice == "y":
9
+ date = datetime.now().strftime("%Y-%m-%d")
10
+ else:
11
+ date = input("Enter the date (YYYY-MM-DD): ").strip()
12
+
13
+ return goal, date
src/ig_story_audit/utils/logging.py ADDED
File without changes
src/ig_story_audit/utils/modes_config.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import configparser
2
+ from pathlib import Path
3
+
4
+ _MODES = None
5
+
6
+ def load_modes():
7
+ global _MODES
8
+ if _MODES is None:
9
+ parser = configparser.ConfigParser()
10
+ parser.read(Path("config/modes.ini"))
11
+ _MODES = parser
12
+ return _MODES
src/ig_story_audit/vision/__init__.py ADDED
File without changes
src/ig_story_audit/vision/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (186 Bytes). View file
 
src/ig_story_audit/vision/__pycache__/openai_client.cpython-312.pyc ADDED
Binary file (2.68 kB). View file
 
src/ig_story_audit/vision/openai_client.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import os
3
+ from openai import OpenAI
4
+
5
+ from ig_story_audit.utils.config import load_config
6
+
7
+
8
+ class OpenAIClient:
9
+ """
10
+ Thin OpenAI API wrapper.
11
+ - Vision calls use vision_model
12
+ - Text / analysis calls use analysis_model
13
+ """
14
+
15
+ def __init__(self, api_key: str | None = None):
16
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY")
17
+ if not self.api_key:
18
+ raise ValueError("OPENAI_API_KEY not set")
19
+
20
+ config = load_config()
21
+
22
+ self.vision_model = config["openai"]["vision_model"]
23
+ self.analysis_model = config["openai"]["analysis_model"]
24
+
25
+ self.client = OpenAI(api_key=self.api_key)
26
+
27
+ def describe_image(self, image_path: str, prompt: str) -> str:
28
+ with open(image_path, "rb") as img:
29
+ image_b64 = base64.b64encode(img.read()).decode("utf-8")
30
+
31
+ image_data_url = f"data:image/jpeg;base64,{image_b64}"
32
+
33
+ response = self.client.responses.create(
34
+ model=self.vision_model,
35
+ input=[
36
+ {
37
+ "role": "user",
38
+ "content": [
39
+ {"type": "input_text", "text": prompt},
40
+ {
41
+ "type": "input_image",
42
+ "image_url": image_data_url,
43
+ },
44
+ ],
45
+ }
46
+ ],
47
+ )
48
+
49
+ return response.output_text
50
+
51
+ def describe_text(self, prompt: str) -> str:
52
+ response = self.client.responses.create(
53
+ model=self.analysis_model,
54
+ input=[
55
+ {
56
+ "role": "user",
57
+ "content": [
58
+ {"type": "input_text", "text": prompt},
59
+ ],
60
+ }
61
+ ],
62
+ )
63
+
64
+ return response.output_text