|
3 | 3 | import math |
4 | 4 | import html |
5 | 5 | import json |
| 6 | +import os |
6 | 7 | import random |
7 | 8 | import re |
8 | 9 | import uuid |
|
28 | 29 | except Exception: # pragma: no cover - optional dependency |
29 | 30 | plt = None |
30 | 31 |
|
| 32 | +from .audio_utils import encode_audio |
| 33 | +from .media_utils import load_image_inputs |
| 34 | + |
31 | 35 |
|
32 | 36 | def _generate_distinct_colors(n: int) -> List[str]: |
33 | 37 | """Generate ``n`` visually distinct hex colors. |
@@ -984,6 +988,49 @@ def show(self): |
984 | 988 | .gabriel-codify-viewer .gabriel-text p { |
985 | 989 | margin: 0 0 1em 0; |
986 | 990 | } |
| 991 | +.gabriel-codify-viewer .gabriel-paraphrase { |
| 992 | + display: flex; |
| 993 | + flex-direction: column; |
| 994 | + gap: 12px; |
| 995 | +} |
| 996 | +.gabriel-codify-viewer .gabriel-paraphrase-label { |
| 997 | + text-transform: uppercase; |
| 998 | + font-size: 10px; |
| 999 | + letter-spacing: 0.12em; |
| 1000 | + color: rgba(255, 255, 255, 0.55); |
| 1001 | + margin-bottom: 6px; |
| 1002 | +} |
| 1003 | +.gabriel-codify-viewer .gabriel-paraphrase-body { |
| 1004 | + font-size: 15px; |
| 1005 | + line-height: 1.7; |
| 1006 | + color: rgba(245, 247, 250, 0.96); |
| 1007 | +} |
| 1008 | +.gabriel-codify-viewer .gabriel-paraphrase-body--emphasis { |
| 1009 | + font-weight: 600; |
| 1010 | +} |
| 1011 | +.gabriel-codify-viewer .gabriel-paraphrase-divider { |
| 1012 | + height: 1px; |
| 1013 | + background: rgba(255, 255, 255, 0.12); |
| 1014 | + border-radius: 999px; |
| 1015 | +} |
| 1016 | +.gabriel-codify-viewer .gabriel-media { |
| 1017 | + display: flex; |
| 1018 | + justify-content: center; |
| 1019 | + align-items: center; |
| 1020 | + padding: 18px; |
| 1021 | + border-radius: 16px; |
| 1022 | + border: 1px solid rgba(255, 255, 255, 0.12); |
| 1023 | + background: rgba(255, 255, 255, 0.04); |
| 1024 | +} |
| 1025 | +.gabriel-codify-viewer .gabriel-media--image img { |
| 1026 | + max-width: min(100%, 520px); |
| 1027 | + height: auto; |
| 1028 | + border-radius: 12px; |
| 1029 | + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.35); |
| 1030 | +} |
| 1031 | +.gabriel-codify-viewer .gabriel-media--audio audio { |
| 1032 | + width: min(480px, 100%); |
| 1033 | +} |
987 | 1034 | .gabriel-codify-viewer .gabriel-snippet { |
988 | 1035 | position: relative; |
989 | 1036 | border-radius: 6px; |
@@ -1052,6 +1099,19 @@ def show(self): |
1052 | 1099 | .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-text { |
1053 | 1100 | color: #1f2933; |
1054 | 1101 | } |
| 1102 | + .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-paraphrase-label { |
| 1103 | + color: rgba(15, 23, 42, 0.6); |
| 1104 | + } |
| 1105 | + .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-paraphrase-body { |
| 1106 | + color: #1f2933; |
| 1107 | + } |
| 1108 | + .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-paraphrase-divider { |
| 1109 | + background: rgba(15, 23, 42, 0.12); |
| 1110 | + } |
| 1111 | + .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-media { |
| 1112 | + background: rgba(15, 23, 42, 0.04); |
| 1113 | + border-color: rgba(15, 23, 42, 0.12); |
| 1114 | + } |
1055 | 1115 | .gabriel-codify-viewer:not(.gabriel-theme-dark) .gabriel-legend-item { |
1056 | 1116 | background: rgba(15, 23, 42, 0.06); |
1057 | 1117 | border-color: rgba(15, 23, 42, 0.12); |
@@ -1600,6 +1660,186 @@ def _format_header_value(value: Any) -> str: |
1600 | 1660 | return str(value) |
1601 | 1661 |
|
1602 | 1662 |
|
| 1663 | +_IMAGE_EXTENSIONS = { |
| 1664 | + ".png", |
| 1665 | + ".jpg", |
| 1666 | + ".jpeg", |
| 1667 | + ".gif", |
| 1668 | + ".bmp", |
| 1669 | + ".webp", |
| 1670 | + ".tiff", |
| 1671 | + ".tif", |
| 1672 | + ".svg", |
| 1673 | +} |
| 1674 | +_AUDIO_EXTENSIONS = { |
| 1675 | + ".mp3", |
| 1676 | + ".wav", |
| 1677 | + ".m4a", |
| 1678 | + ".aac", |
| 1679 | + ".ogg", |
| 1680 | + ".flac", |
| 1681 | + ".opus", |
| 1682 | +} |
| 1683 | +_IMAGE_MIME_MAP = { |
| 1684 | + ".png": "image/png", |
| 1685 | + ".jpg": "image/jpeg", |
| 1686 | + ".jpeg": "image/jpeg", |
| 1687 | + ".gif": "image/gif", |
| 1688 | + ".bmp": "image/bmp", |
| 1689 | + ".webp": "image/webp", |
| 1690 | + ".tiff": "image/tiff", |
| 1691 | + ".tif": "image/tiff", |
| 1692 | + ".svg": "image/svg+xml", |
| 1693 | +} |
| 1694 | + |
| 1695 | + |
| 1696 | +def _looks_like_base64(text: str) -> bool: |
| 1697 | + if not text: |
| 1698 | + return False |
| 1699 | + cleaned = text.strip() |
| 1700 | + if len(cleaned) < 120: |
| 1701 | + return False |
| 1702 | + return bool(re.fullmatch(r"[A-Za-z0-9+/=\s]+", cleaned)) |
| 1703 | + |
| 1704 | + |
| 1705 | +def _resolve_paraphrase_columns( |
| 1706 | + df: pd.DataFrame, |
| 1707 | + column_name: str, |
| 1708 | +) -> Optional[Tuple[str, str]]: |
| 1709 | + base = f"{column_name}_revised" |
| 1710 | + if base in df.columns: |
| 1711 | + return column_name, base |
| 1712 | + pattern = re.compile(rf"^{re.escape(base)}_(\d+)$") |
| 1713 | + matches: List[Tuple[int, str]] = [] |
| 1714 | + for col in df.columns: |
| 1715 | + match = pattern.match(str(col)) |
| 1716 | + if match: |
| 1717 | + try: |
| 1718 | + matches.append((int(match.group(1)), str(col))) |
| 1719 | + except ValueError: |
| 1720 | + continue |
| 1721 | + if matches: |
| 1722 | + matches.sort(key=lambda item: item[0]) |
| 1723 | + return column_name, matches[0][1] |
| 1724 | + return None |
| 1725 | + |
| 1726 | + |
| 1727 | +def _media_from_value(value: Any) -> Optional[Dict[str, str]]: |
| 1728 | + if _is_na(value): |
| 1729 | + return None |
| 1730 | + if isinstance(value, (list, tuple, set)): |
| 1731 | + for item in value: |
| 1732 | + media = _media_from_value(item) |
| 1733 | + if media: |
| 1734 | + return media |
| 1735 | + return None |
| 1736 | + if isinstance(value, dict): |
| 1737 | + file_url = value.get("file_url") or value.get("url") |
| 1738 | + if isinstance(file_url, str) and file_url.strip(): |
| 1739 | + return {"type": "audio", "src": file_url.strip()} |
| 1740 | + data = value.get("data") |
| 1741 | + if isinstance(data, str) and data.strip(): |
| 1742 | + fmt = value.get("format") or "wav" |
| 1743 | + return {"type": "audio", "src": f"data:audio/{fmt};base64,{data}"} |
| 1744 | + return None |
| 1745 | + if not isinstance(value, str): |
| 1746 | + return None |
| 1747 | + text = value.strip() |
| 1748 | + if not text: |
| 1749 | + return None |
| 1750 | + lowered = text.lower() |
| 1751 | + if lowered.startswith("data:image"): |
| 1752 | + return {"type": "image", "src": text} |
| 1753 | + if lowered.startswith("data:audio"): |
| 1754 | + return {"type": "audio", "src": text} |
| 1755 | + if lowered.startswith("http://") or lowered.startswith("https://"): |
| 1756 | + ext = os.path.splitext(lowered)[1] |
| 1757 | + if ext in _IMAGE_EXTENSIONS: |
| 1758 | + return {"type": "image", "src": text} |
| 1759 | + if ext in _AUDIO_EXTENSIONS: |
| 1760 | + return {"type": "audio", "src": text} |
| 1761 | + return None |
| 1762 | + ext = os.path.splitext(lowered)[1] |
| 1763 | + if os.path.exists(text): |
| 1764 | + if ext in _IMAGE_EXTENSIONS: |
| 1765 | + encoded = load_image_inputs(text) |
| 1766 | + if encoded: |
| 1767 | + mime = _IMAGE_MIME_MAP.get(ext, "image/png") |
| 1768 | + return {"type": "image", "src": f"data:{mime};base64,{encoded[0]}"} |
| 1769 | + if ext in _AUDIO_EXTENSIONS: |
| 1770 | + encoded_audio = encode_audio(text) |
| 1771 | + if encoded_audio and encoded_audio.get("data"): |
| 1772 | + fmt = encoded_audio.get("format") or ext.lstrip(".") |
| 1773 | + return { |
| 1774 | + "type": "audio", |
| 1775 | + "src": f"data:audio/{fmt};base64,{encoded_audio['data']}", |
| 1776 | + } |
| 1777 | + return None |
| 1778 | + if _looks_like_base64(text): |
| 1779 | + return {"type": "image", "src": f"data:image/png;base64,{text}"} |
| 1780 | + return None |
| 1781 | + |
| 1782 | + |
| 1783 | +def _build_media_html(media: Dict[str, str]) -> str: |
| 1784 | + media_type = media.get("type") |
| 1785 | + src = media.get("src", "") |
| 1786 | + safe_src = html.escape(src, quote=True) |
| 1787 | + if media_type == "image": |
| 1788 | + return ( |
| 1789 | + "<div class='gabriel-media gabriel-media--image'>" |
| 1790 | + f"<img src='{safe_src}' alt='Image input'/>" |
| 1791 | + "</div>" |
| 1792 | + ) |
| 1793 | + if media_type == "audio": |
| 1794 | + return ( |
| 1795 | + "<div class='gabriel-media gabriel-media--audio'>" |
| 1796 | + f"<audio controls src='{safe_src}'></audio>" |
| 1797 | + "</div>" |
| 1798 | + ) |
| 1799 | + return "" |
| 1800 | + |
| 1801 | + |
| 1802 | +def _build_paraphrase_html(paraphrased: str, original: str) -> str: |
| 1803 | + return ( |
| 1804 | + "<div class='gabriel-paraphrase'>" |
| 1805 | + "<div class='gabriel-paraphrase-section'>" |
| 1806 | + "<div class='gabriel-paraphrase-label'>Paraphrased text</div>" |
| 1807 | + "<div class='gabriel-paraphrase-body gabriel-paraphrase-body--emphasis'>" |
| 1808 | + f"{paraphrased}</div>" |
| 1809 | + "</div>" |
| 1810 | + "<div class='gabriel-paraphrase-divider'></div>" |
| 1811 | + "<div class='gabriel-paraphrase-section'>" |
| 1812 | + "<div class='gabriel-paraphrase-label'>Original text</div>" |
| 1813 | + f"<div class='gabriel-paraphrase-body'>{original}</div>" |
| 1814 | + "</div>" |
| 1815 | + "</div>" |
| 1816 | + ) |
| 1817 | + |
| 1818 | + |
| 1819 | +def _build_passage_body( |
| 1820 | + original_text: str, |
| 1821 | + revised_text: Optional[str], |
| 1822 | + snippet_map: Dict[str, List[str]], |
| 1823 | + category_colors: Dict[str, str], |
| 1824 | + category_labels: Dict[str, str], |
| 1825 | + media: Optional[Dict[str, str]] = None, |
| 1826 | +) -> str: |
| 1827 | + if media: |
| 1828 | + original_html = _build_media_html(media) |
| 1829 | + else: |
| 1830 | + original_html = _build_highlighted_text( |
| 1831 | + original_text, snippet_map, category_colors, category_labels |
| 1832 | + ) |
| 1833 | + if revised_text is None: |
| 1834 | + return original_html |
| 1835 | + revised_value = revised_text if revised_text is not None else "" |
| 1836 | + if revised_value and revised_value.strip(): |
| 1837 | + paraphrased_html = html.escape(revised_value).replace("\n", "<br/>") |
| 1838 | + else: |
| 1839 | + paraphrased_html = "<div class='gabriel-empty'>No paraphrased text available.</div>" |
| 1840 | + return _build_paraphrase_html(paraphrased_html, original_html) |
| 1841 | + |
| 1842 | + |
1603 | 1843 | def _build_highlighted_text( |
1604 | 1844 | text: str, |
1605 | 1845 | snippet_map: Dict[str, List[str]], |
@@ -1921,6 +2161,7 @@ def _render_passage_viewer( |
1921 | 2161 |
|
1922 | 2162 | df = _normalize_structured_dataframe(df, snippet_columns) |
1923 | 2163 | normalized_headers = _normalize_header_columns(header_columns) |
| 2164 | + paraphrase_columns = _resolve_paraphrase_columns(df, column_name) |
1924 | 2165 |
|
1925 | 2166 | numeric_bounds: Dict[str, List[Optional[float]]] = { |
1926 | 2167 | spec.column: [None, None] for spec in numeric_specs |
@@ -1959,8 +2200,16 @@ def _render_passage_viewer( |
1959 | 2200 |
|
1960 | 2201 | passages: List[Dict[str, Any]] = [] |
1961 | 2202 | for _, row in df.iterrows(): |
1962 | | - raw_text = row.get(column_name) |
| 2203 | + if paraphrase_columns: |
| 2204 | + original_column, revised_column = paraphrase_columns |
| 2205 | + raw_text = row.get(original_column) |
| 2206 | + raw_revised = row.get(revised_column) |
| 2207 | + revised_text = "" if _is_na(raw_revised) else str(raw_revised) |
| 2208 | + else: |
| 2209 | + raw_text = row.get(column_name) |
| 2210 | + revised_text = None |
1963 | 2211 | text = "" if _is_na(raw_text) else str(raw_text) |
| 2212 | + media = _media_from_value(raw_text) |
1964 | 2213 | snippet_map: Dict[str, List[str]] = {cat: [] for cat in category_names} |
1965 | 2214 | bool_values: Dict[str, Optional[bool]] = {} |
1966 | 2215 | numeric_values: Dict[str, Optional[float]] = {} |
@@ -2021,6 +2270,8 @@ def _render_passage_viewer( |
2021 | 2270 | passages.append( |
2022 | 2271 | { |
2023 | 2272 | "text": text, |
| 2273 | + "revised": revised_text, |
| 2274 | + "media": media, |
2024 | 2275 | "snippets": snippet_map, |
2025 | 2276 | "header": header_rows, |
2026 | 2277 | "active": active_categories, |
@@ -2079,8 +2330,13 @@ def _render_passage_viewer( |
2079 | 2330 |
|
2080 | 2331 | render_entries: List[Dict[str, Any]] = [] |
2081 | 2332 | for idx, payload in enumerate(passages): |
2082 | | - body_html = _build_highlighted_text( |
2083 | | - payload["text"], payload["snippets"], category_colors, category_labels |
| 2333 | + body_html = _build_passage_body( |
| 2334 | + payload["text"], |
| 2335 | + payload.get("revised"), |
| 2336 | + payload["snippets"], |
| 2337 | + category_colors, |
| 2338 | + category_labels, |
| 2339 | + media=payload.get("media"), |
2084 | 2340 | ) |
2085 | 2341 | header_html = _build_header_html( |
2086 | 2342 | payload["header"], payload["active"] |
|
0 commit comments